From 3b7bf9158b774b384fa28afe46e8cb648abc9eab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 11:33:23 -0700 Subject: [PATCH 01/55] feat(v2): download run output files by API key Adds GET /api/v2/workflows/{id}/runs/{runId}/files/{fileId}, closing the async-run loop for headless callers. A run's output carries UserFile URLs pointing at /api/files/serve/..., which rejects x-api-key outright, so an async run that produces a file previously had no byte path out for an API key at all. The file is addressed by the id the run reported and resolved against the run's own recorded execution data, from which the storage key is read. The request never supplies a storage key, so the endpoint cannot be aimed at bytes the run did not produce. Resolution deliberately reads the materialized-but-undisplayed recording, because the display projection strips exactly the `key`/`context` fields a byte read needs. Also hardens normalizeStartFile to derive a file's storage key only from a validated internal serve URL, discarding any caller-supplied `key`/`context`. A workspace API key has no human subject, so the executor resolves its actor to the workspace billing owner (preprocessing.ts -> resolveSystemBillingAttribution); verifyFileAccess then authorizes a workspace-context key as that owner, whose reach is not bounded by the key's workspace. Accepting an attacker-authorable key made that substitution exploitable as a confused deputy. Normalization is all-or-nothing, so a forged file now drops the whole files input. --- apps/docs/openapi-v2-workflows.json | 129 ++++++++++ .../runs/[runId]/files/[fileId]/route.test.ts | 215 +++++++++++++++++ .../[id]/runs/[runId]/files/[fileId]/route.ts | 51 ++++ apps/sim/executor/utils/start-block.test.ts | 98 +++++++- apps/sim/executor/utils/start-block.ts | 37 +-- .../api/contracts/v2/openapi/files-audit.ts | 31 +-- .../lib/api/contracts/v2/openapi/shared.ts | 33 +++ .../lib/api/contracts/v2/openapi/workflows.ts | 23 +- apps/sim/lib/api/contracts/v2/workflows.ts | 34 +++ apps/sim/lib/core/utils/user-file.ts | 47 ++++ .../download-workflow-run-file.test.ts | 222 ++++++++++++++++++ .../application/download-workflow-run-file.ts | 120 ++++++++++ .../lib/workflows/application/operations.ts | 13 + .../workflows/executor/execution-run-files.ts | 78 ++++++ scripts/check-api-validation-contracts.ts | 4 +- 15 files changed, 1083 insertions(+), 52 deletions(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/runs/[runId]/files/[fileId]/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/runs/[runId]/files/[fileId]/route.ts create mode 100644 apps/sim/lib/workflows/application/download-workflow-run-file.test.ts create mode 100644 apps/sim/lib/workflows/application/download-workflow-run-file.ts create mode 100644 apps/sim/lib/workflows/executor/execution-run-files.ts diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 6d43438c88b..fecfa454670 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -1490,6 +1490,110 @@ } } }, + "/api/v2/workflows/{id}/runs/{runId}/files/{fileId}": { + "get": { + "operationId": "downloadWorkflowRunFileV2", + "summary": "Download Workflow Run File", + "description": "Download one file a run produced. The run resource reports the files a run emitted; address one of them by its `id` here. Run output carries `/api/files/serve/...` URLs that reject API keys, so this is the byte path out of a run for an API-key caller. Execution objects are not retained indefinitely, so a `404` for a file an older run produced is expected rather than a fault. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", + "tags": ["Workflow Runs"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier." + } + }, + { + "name": "runId", + "in": "path", + "required": true, + "description": "Unique workflow run identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + } + }, + { + "name": "fileId", + "in": "path", + "required": true, + "description": "Identifier of a file the run produced, as reported by the run resource.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Identifier of a file the run produced, as reported by the run resource." + } + } + ], + "responses": { + "200": { + "description": "The run file bytes.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + }, + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "Content-Length": { + "$ref": "#/components/headers/Content-Length" + } + }, + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/workflows/{id}/runs/{runId}/resume": { "post": { "operationId": "resumeWorkflowRunV2", @@ -2072,6 +2176,31 @@ } }, "headers": { + "Content-Type": { + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", + "schema": { + "type": "string", + "title": "Content type", + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." + } + }, + "Content-Disposition": { + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", + "schema": { + "type": "string", + "title": "Content disposition", + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." + } + }, + "Content-Length": { + "description": "File size in bytes.", + "schema": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)$", + "title": "Content length", + "description": "File size in bytes." + } + }, "X-RateLimit-Limit": { "description": "Maximum requests allowed in the current window.", "schema": { diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/files/[fileId]/route.test.ts new file mode 100644 index 00000000000..90cec74fc16 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/files/[fileId]/route.test.ts @@ -0,0 +1,215 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + download: vi.fn(), + authorizeDownload: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/download-workflow-run-file', () => ({ + downloadWorkflowRunFileStream: { + operation: { + id: 'workflows.download_run_file', + minimumRole: 'read', + workspaceApiKey: 'allow', + }, + execute: mocks.download, + authorize: mocks.authorizeDownload, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/v2/workflows/[id]/runs/[runId]/files/[fileId]/route' + +const WORKSPACE_ID = 'workspace-1' +const WORKFLOW_ID = '3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36' +const RUN_ID = 'run_8f14e45f-ceea-467f-a' +const FILE_ID = 'file_report' + +const context = { + params: Promise.resolve({ id: WORKFLOW_ID, runId: RUN_ID, fileId: FILE_ID }), +} + +const workspaceKeyAuth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const personalKeyAuth = { + principal: { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-2', + }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-2'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +function url(): string { + return `http://localhost:3000/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}/files/${FILE_ID}` +} + +function getRequest(): NextRequest { + return new NextRequest(url()) +} + +function headRequest(): NextRequest { + return new NextRequest(url(), { method: 'HEAD' }) +} + +describe('GET /api/v2/workflows/[id]/runs/[runId]/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(workspaceKeyAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.authorizeDownload.mockResolvedValue(undefined) + mocks.download.mockResolvedValue({ + file: { id: FILE_ID, name: 'report.pdf', key: 'execution/ws/wf/run/report.pdf', size: 3 }, + stream: new Blob(['pdf']).stream(), + contentType: 'application/pdf', + contentLength: 3, + }) + }) + + /** + * The regression test for the whole cluster: run output carries + * `/api/files/serve/...` URLs that reject `x-api-key` outright, so a + * workspace key succeeding here is the byte path that previously did not + * exist for an async run. + */ + it('serves run bytes to a workspace API key', async () => { + const response = await GET(getRequest(), context) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/pdf') + expect(response.headers.get('Content-Disposition')).toContain('report.pdf') + expect(response.headers.get('Content-Length')).toBe('3') + expect(await response.text()).toBe('pdf') + }) + + it('serves run bytes to a personal API key', async () => { + v2RouteMocks.authenticate.mockResolvedValueOnce(personalKeyAuth) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('pdf') + }) + + /** + * The caller addresses a file by id only. Nothing resembling a storage key + * reaches the use case, so the endpoint cannot be aimed at other bytes. + */ + it('passes only the path identifiers to the use case', async () => { + await GET(getRequest(), context) + + expect(mocks.download).toHaveBeenCalledWith({ + principal: workspaceKeyAuth.principal, + input: { workflowId: WORKFLOW_ID, runId: RUN_ID, fileId: FILE_ID }, + request: expect.anything(), + }) + }) + + it('sets private, no-store caching on the bytes', async () => { + const response = await GET(getRequest(), context) + + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + }) + + it('surfaces operation rate-limit headers', async () => { + const response = await GET(getRequest(), context) + + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + }) + + it('rejects an unauthenticated request', async () => { + v2RouteMocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(401) + expect((await response.json()).error.code).toBe('UNAUTHORIZED') + }) + + /** Cross-tenant reads must 404, never 403 — a 403 confirms the run exists. */ + it('conceals a run in another workspace as 404', async () => { + mocks.download.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('reports a run that has not finished as a conflict', async () => { + mocks.download.mockRejectedValueOnce( + new OrchestrationError('conflict', 'Run has not finished yet') + ) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(409) + }) + + /** + * `headSafe: false`: a `HEAD` authorizes and answers bodiless without running + * the download, so it never records a `FILE_DOWNLOADED` audit event and never + * becomes an existence oracle for a file the `GET` would 404. + */ + it('answers an authorized HEAD bodiless without downloading', async () => { + const response = await GET(headRequest(), context) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('') + expect(mocks.download).not.toHaveBeenCalled() + expect(mocks.authorizeDownload).toHaveBeenCalledOnce() + }) + + it('does not confirm via HEAD a run the caller cannot reach', async () => { + mocks.authorizeDownload.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await GET(headRequest(), context) + + expect(response.status).toBe(404) + expect(mocks.download).not.toHaveBeenCalled() + }) + + it('does not confirm via HEAD a file id that does not exist', async () => { + mocks.authorizeDownload.mockRejectedValueOnce( + new OrchestrationError('not_found', 'File not found') + ) + + const response = await GET(headRequest(), context) + + expect(response.status).toBe(404) + expect(mocks.download).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/files/[fileId]/route.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/files/[fileId]/route.ts new file mode 100644 index 00000000000..1a2592cffb0 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/files/[fileId]/route.ts @@ -0,0 +1,51 @@ +import { v2DownloadRunFileContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { downloadWorkflowRunFileStream } from '@/lib/workflows/application/download-workflow-run-file' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { encodeFilenameForHeader } from '@/app/api/files/utils' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/workflows/[id]/runs/[runId]/files/[fileId] — download a file a + * run produced (binary). + * + * This is the byte path out of an async run for an API-key caller: the + * `UserFile` URLs carried in a run's output point at `/api/files/serve/...`, + * which rejects `x-api-key` outright. + * + * The file is addressed by the id reported on the run resource and resolved + * against the run's own recorded output, from which the storage key is read. + * The request never supplies a storage key, so the endpoint cannot be aimed at + * bytes the run did not produce. + * + * Execution files are not retained forever; a `404` after a run's objects have + * been collected is expected rather than a fault. Unknown run, unknown file, + * cross-tenant run, and expired object all render the same `File not found` + * so the response cannot be used to probe which ids exist. + * + * `headSafe: false` because downloading records a `FILE_DOWNLOADED` audit event + * and pulls the bytes out of object storage. + */ +export const GET = defineV2BinaryRoute({ + contract: v2DownloadRunFileContract, + auth: v2ApiKeyAuth, + headSafe: false, + operation: workflowOperations.downloadRunFile, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealRunAuthorization, + mapInput: ({ params }) => ({ + workflowId: params.id, + runId: params.runId, + fileId: params.fileId, + }), + useCase: downloadWorkflowRunFileStream, + present: ({ file, stream, contentType, contentLength }) => ({ + body: stream, + contentType, + contentDisposition: `attachment; ${encodeFilenameForHeader(file.name)}`, + contentLength, + }), +}) diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 98b5c80d15d..d70b58afd55 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -96,10 +96,11 @@ describe('start-block utilities', () => { { id: 'file-1', name: 'document.txt', - url: 'https://example.com/document.txt', + url: '/api/files/serve/s3/workspace%2Fworkspace-id%2Fdocument.txt?context=workspace', size: 42, type: 'text/plain', - key: 'file-key', + key: 'workspace/workspace-id/document.txt', + context: 'workspace', }, ] @@ -155,6 +156,99 @@ describe('start-block utilities', () => { ]) }) + it.concurrent('drops caller-supplied storage keys that no internal URL backs', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'victim.pdf', + url: 'https://example.com/victim.pdf', + size: 1024, + type: 'application/pdf', + key: 'workspace/other-tenant-workspace/victim.pdf', + context: 'workspace', + }, + ], + }, + }) + + expect(output.files).toBeUndefined() + }) + + it.concurrent('derives the storage key from the internal URL, not the forged key', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'screenshot.png', + url: '/api/files/serve/s3/execution%2Fworkspace-id%2Fworkflow-id%2Fexecution-id%2Fscreenshot.png?context=execution', + size: 243289, + type: 'image/png', + key: 'workspace/other-tenant-workspace/victim.pdf', + context: 'workspace', + }, + ], + }, + }) + + expect(output.files).toEqual([ + { + id: 'file_1', + name: 'screenshot.png', + url: '/api/files/serve/s3/execution%2Fworkspace-id%2Fworkflow-id%2Fexecution-id%2Fscreenshot.png?context=execution', + size: 243289, + type: 'image/png', + key: 'execution/workspace-id/workflow-id/execution-id/screenshot.png', + context: 'execution', + }, + ]) + }) + + it.concurrent('rejects a malformed internal URL rather than falling back to a forged key', () => { + const block = createBlock('start_trigger', 'start') + const resolution = { + blockId: 'start', + block, + path: StartBlockPath.UNIFIED, + } as const + + const output = buildStartBlockOutput({ + resolution, + workflowInput: { + files: [ + { + id: 'file_1', + name: 'victim.pdf', + url: '/api/files/serve/', + size: 1024, + type: 'application/pdf', + key: 'workspace/other-tenant-workspace/victim.pdf', + }, + ], + }, + }) + + expect(output.files).toBeUndefined() + }) + it.concurrent('rejects inputFormat fields that collide with executor routing keys', () => { const block = createBlock('start_trigger', 'start', { subBlocks: { diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index a40fc42260a..e328cd666da 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -1,9 +1,5 @@ import { isRecordLike } from '@sim/utils/object' -import { - inferContextFromKey, - isInternalFileUrl, - parseInternalFileUrl, -} from '@/lib/uploads/utils/file-utils' +import { isInternalFileUrl, parseInternalFileUrl } from '@/lib/uploads/utils/file-utils' import { classifyStartBlockType, resolveStartCandidates, @@ -334,6 +330,20 @@ function getRawInputCandidate(workflowInput: unknown): unknown { return workflowInput } +/** + * Normalizes one caller-supplied file object into the executor's canonical + * {@link UserFile}, or returns `null` when the shape is not usable. + * + * The storage `key` and `context` are derived **only** by parsing a validated + * internal `/api/files/serve/...` URL; a caller-supplied `key` or `context` is + * discarded rather than trusted. A storage key names a specific tenant's bytes, + * so honoring one from a request body would make this normalizer an + * attacker-authorable source of storage addresses. Every byte-reading consumer + * re-authorizes the key before reading, but accepting it here would leave that + * safety entirely downstream, where a future consumer can omit it with no + * compile-time signal. Files that carry no recoverable internal URL are + * rejected instead of being normalized around an asserted key. + */ function normalizeStartFile(file: unknown): UserFile | null { if (!isRecordLike(file)) { return null @@ -345,29 +355,20 @@ function normalizeStartFile(file: unknown): UserFile | null { typeof file.url === 'string' ? file.url : typeof file.path === 'string' ? file.path : '' const size = typeof file.size === 'number' ? file.size : Number.NaN const type = typeof file.type === 'string' ? file.type : '' - const explicitKey = typeof file.key === 'string' ? file.key : '' - let key = explicitKey - let context = typeof file.context === 'string' ? file.context : undefined + let key = '' + let context: string | undefined - if (!key && url && isInternalFileUrl(url)) { + if (url && isInternalFileUrl(url)) { try { const parsed = parseInternalFileUrl(url) key = parsed.key - context = context || parsed.context + context = parsed.context } catch { return null } } - if (!context && key) { - try { - context = inferContextFromKey(key) - } catch { - // Older file outputs may have opaque keys; keep the file shape intact. - } - } - if (!id || !name || !url || !Number.isFinite(size) || !type || !key) { return null } diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 26182ca344e..f44ccfc8ccd 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -1,4 +1,3 @@ -import { z } from 'zod' import { v2GetAuditLogContract, v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' import { v2AbortFileUploadContract, @@ -34,6 +33,7 @@ import { RESOURCE_ERRORS, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, + V2_BINARY_DOWNLOAD_HEADERS, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, @@ -873,34 +873,7 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ ], security: V2_API_KEY_SECURITY, securitySchemes: V2_API_KEY_SECURITY_SCHEMES, - headers: { - 'Content-Type': { - schema: z.string().meta({ - id: 'ContentTypeHeader', - title: 'Content type', - description: - 'MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.', - }), - }, - 'Content-Disposition': { - schema: z.string().meta({ - id: 'ContentDispositionHeader', - title: 'Content disposition', - description: 'Attachment disposition containing sanitized and RFC 5987 encoded filenames.', - }), - }, - 'Content-Length': { - schema: z - .string() - .regex(/^(0|[1-9]\d*)$/) - .meta({ - id: 'ContentLengthHeader', - title: 'Content length', - description: 'File size in bytes.', - }), - }, - ...V2_COMMON_HEADERS, - }, + headers: { ...V2_BINARY_DOWNLOAD_HEADERS, ...V2_COMMON_HEADERS }, errorSchema: V2_ERROR_SCHEMA, errorResponses: withErrorExamples({ Conflict: { message: 'File already exists' }, diff --git a/apps/sim/lib/api/contracts/v2/openapi/shared.ts b/apps/sim/lib/api/contracts/v2/openapi/shared.ts index 0a774a71c5b..3a7eaca6b05 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/shared.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/shared.ts @@ -401,6 +401,39 @@ export const WORKSPACE_API_KEY_DENIED_AS_NOT_FOUND = export const RUN_RETENTION = "Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override." +/** + * Response headers a binary download declares on top of the common set. Shared + * so every document that publishes a byte-serving route describes the same + * three headers identically. + */ +export const V2_BINARY_DOWNLOAD_HEADERS = { + 'Content-Type': { + schema: z.string().meta({ + id: 'ContentTypeHeader', + title: 'Content type', + description: + 'MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.', + }), + }, + 'Content-Disposition': { + schema: z.string().meta({ + id: 'ContentDispositionHeader', + title: 'Content disposition', + description: 'Attachment disposition containing sanitized and RFC 5987 encoded filenames.', + }), + }, + 'Content-Length': { + schema: z + .string() + .regex(/^(0|[1-9]\d*)$/) + .meta({ + id: 'ContentLengthHeader', + title: 'Content length', + description: 'File size in bytes.', + }), + }, +} as const + export const V2_COMMON_HEADERS = { 'X-RateLimit-Limit': { schema: z.number().int().nonnegative().meta({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index ef62bb6fcdf..6169e761350 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -5,6 +5,7 @@ import { FOLDER_TREE_TOO_LARGE, FULL_SET_LIST, HEAD_MIRRORS_GET, + HEAD_OMITS_PAYLOAD_HEADERS, RATE_LIMIT_HEADERS, RESOURCE_CONFLICT_ERRORS, RESOURCE_ERRORS, @@ -12,6 +13,7 @@ import { RUN_RETENTION, V2_API_KEY_SECURITY, V2_API_KEY_SECURITY_SCHEMES, + V2_BINARY_DOWNLOAD_HEADERS, V2_COMMON_HEADERS, V2_ERROR_SCHEMA, WORKSPACE_API_KEY_DENIED, @@ -26,6 +28,7 @@ import { v2DeleteWorkflowContract, v2DeleteWorkflowFolderContract, v2DeployWorkflowContract, + v2DownloadRunFileContract, v2ExecuteWorkflowContract, v2ExecuteWorkflowQueuedResponseSchema, v2ExecuteWorkflowSyncResponseSchema, @@ -677,6 +680,24 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2DownloadRunFileContract, + workflowRunOperation({ + operationId: 'downloadWorkflowRunFileV2', + summary: 'Download Workflow Run File', + description: `Download one file a run produced. The run resource reports the files a run emitted; address one of them by its \`id\` here. Run output carries \`/api/files/serve/...\` URLs that reject API keys, so this is the byte path out of a run for an API-key caller. Execution objects are not retained indefinitely, so a \`404\` for a file an older run produced is expected rather than a fault. ${RUN_RETENTION} Downloading records an audit event, so it is not a safe read. ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, + errors: [...RESOURCE_CONFLICT_ERRORS], + success: { + description: 'The run file bytes.', + headers: [...RATE_LIMIT_HEADERS, 'Content-Type', 'Content-Disposition', 'Content-Length'], + contentTypes: ['application/octet-stream'], + }, + }), + { + params: v2DownloadRunFileContract.params, + query: v2DownloadRunFileContract.query, + } + ), defineOpenApiRoute( v2ResumeWorkflowContract, workflowRunOperation({ @@ -894,7 +915,7 @@ export const workflowsOpenApiDocument = defineOpenApiDocument({ ], security: V2_API_KEY_SECURITY, securitySchemes: V2_API_KEY_SECURITY_SCHEMES, - headers: V2_COMMON_HEADERS, + headers: { ...V2_BINARY_DOWNLOAD_HEADERS, ...V2_COMMON_HEADERS }, errorSchema: V2_ERROR_SCHEMA, errorResponses: ERROR_RESPONSES, routes, diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 4432437b508..3991cb92e55 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1301,6 +1301,40 @@ export const v2WorkflowRunStatusSchema = z }) export type V2WorkflowRunStatus = z.output +export const v2DownloadRunFileParamsSchema = z + .object({ + id: z.string().min(1, 'Invalid workflow ID').describe('Unique workflow identifier.'), + runId: v2WorkflowRunIdSchema.describe('Unique workflow run identifier.'), + fileId: z + .string() + .min(1, 'fileId cannot be empty') + .max(256, 'fileId is too long') + .describe('Identifier of a file the run produced, as reported by the run resource.'), + }) + .meta({ + id: 'DownloadRunFileParams', + title: 'Run file path parameters', + description: 'Workflow, run, and run-produced file selected by the request path.', + }) +export type V2DownloadRunFileParams = z.input + +/** + * Downloads one file a run produced. + * + * The file is addressed by the id the run itself reported; a storage key is + * never accepted from the request, so this endpoint cannot be pointed at bytes + * the run did not produce. + */ +export const v2DownloadRunFileContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/runs/[runId]/files/[fileId]', + params: v2DownloadRunFileParamsSchema, + query: noInputSchema, + response: { + mode: 'binary', + }, +}) + export const v2GetWorkflowRunContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[id]/runs/[runId]', diff --git a/apps/sim/lib/core/utils/user-file.ts b/apps/sim/lib/core/utils/user-file.ts index 546c9dbc4cb..f2e8f166493 100644 --- a/apps/sim/lib/core/utils/user-file.ts +++ b/apps/sim/lib/core/utils/user-file.ts @@ -78,6 +78,53 @@ function collectUserFileKeysInto(value: unknown, keys: Set, seen: WeakSe } } +/** + * Collects the {@link UserFile} records embedded in a value, indexed by file id. + * + * The first occurrence of an id wins, matching `Array.prototype.find`, so a file + * echoed into several block outputs resolves to a single record. Callers use + * this to answer "which files did this value actually reference, and under which + * storage keys" without trusting an id-to-key mapping supplied from outside. + */ +export function collectUserFilesById(value: unknown): Map { + const files = new Map() + collectUserFilesInto(value, files, new WeakSet()) + return files +} + +function collectUserFilesInto( + value: unknown, + files: Map, + seen: WeakSet +): void { + if (!value || typeof value !== 'object') { + return + } + + if (seen.has(value)) { + return + } + seen.add(value) + + if (isUserFileWithMetadata(value)) { + if (!files.has(value.id)) { + files.set(value.id, value) + } + return + } + + if (Array.isArray(value)) { + for (const item of value) { + collectUserFilesInto(item, files, seen) + } + return + } + + for (const item of Object.values(value)) { + collectUserFilesInto(item, files, seen) + } +} + /** * Checks if a value matches the display-safe UserFile metadata shape after internal fields are stripped. */ diff --git a/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts b/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts new file mode 100644 index 00000000000..a59dcedd419 --- /dev/null +++ b/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts @@ -0,0 +1,222 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getRunFiles: vi.fn(), + downloadFileStream: vi.fn(), + resolvePermission: vi.fn(), + resolveRunContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowRunApplicationContext: mocks.resolveRunContext, +})) + +vi.mock('@/lib/workflows/executor/execution-run-files', () => ({ + getWorkflowRunFiles: mocks.getRunFiles, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mocks.downloadFileStream, +})) + +import { Readable } from 'node:stream' +import { downloadWorkflowRunFileStream } from '@/lib/workflows/application/download-workflow-run-file' + +const WORKFLOW_ID = 'workflow-1' +const RUN_ID = 'run-1' +const FILE_ID = 'file_report' +const FILE_KEY = 'execution/workspace-1/workflow-1/run-1/report.pdf' + +const runContext = { + workflowId: WORKFLOW_ID, + workflow: { id: WORKFLOW_ID }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + runId: RUN_ID, +} + +const principals: Principal[] = [ + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-personal' }, + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-workspace' }, + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2999-01-01T00:00:00Z'), + }, +] + +const workspaceKeyPrincipal = principals[2] + +function runFile(overrides: Record = {}) { + return { + id: FILE_ID, + name: 'report.pdf', + url: `/api/files/serve/s3/${encodeURIComponent(FILE_KEY)}`, + size: 3, + type: 'application/pdf', + key: FILE_KEY, + ...overrides, + } +} + +function terminalRun(files: Record[] = [runFile()]) { + return { + terminal: true, + workspaceId: 'workspace-1', + filesById: new Map(files.map((file) => [file.id as string, file])), + } +} + +function input(overrides: Record = {}) { + return { workflowId: WORKFLOW_ID, runId: RUN_ID, fileId: FILE_ID, ...overrides } +} + +describe('downloadWorkflowRunFileStream', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveRunContext.mockResolvedValue(runContext) + mocks.getRunFiles.mockResolvedValue(terminalRun()) + mocks.downloadFileStream.mockResolvedValue(Readable.from([Buffer.from('pdf')])) + }) + + it.each(principals)('allows $kind at the read role', async (principal) => { + const result = await downloadWorkflowRunFileStream.execute({ principal, input: input() }) + + expect(result.file.id).toBe(FILE_ID) + expect(result.contentType).toBe('application/pdf') + expect(result.contentLength).toBe(3) + }) + + it('denies a principal below the read role', async () => { + mocks.resolvePermission.mockResolvedValue(null) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: principals[0], input: input() }) + ).rejects.toThrow() + expect(mocks.downloadFileStream).not.toHaveBeenCalled() + }) + + /** + * The key-derivation invariant: the storage key handed to the object store is + * read off the run's own recording, so a caller can only ever reach bytes the + * addressed run produced. + */ + it('takes the storage key from the run record, not the request', async () => { + await downloadWorkflowRunFileStream.execute({ + principal: workspaceKeyPrincipal, + input: input({ key: 'execution/other-workspace/wf/run/secret.pdf' } as never), + }) + + expect(mocks.downloadFileStream).toHaveBeenCalledWith({ + key: FILE_KEY, + context: 'execution', + }) + }) + + it('resolves the run canonically before authorizing or reading', async () => { + mocks.resolveRunContext.mockRejectedValueOnce( + Object.assign(new Error('Run not found'), { code: 'not_found' }) + ) + + await expect( + downloadWorkflowRunFileStream.execute({ + principal: workspaceKeyPrincipal, + input: input({ workflowId: 'other-workflow' }), + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getRunFiles).not.toHaveBeenCalled() + }) + + it('reports an unknown run as not found', async () => { + mocks.getRunFiles.mockResolvedValueOnce(null) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: workspaceKeyPrincipal, input: input() }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + }) + + /** + * A file id belonging to a different run of the same workflow must not + * resolve — the run's own recording is the only index consulted. + */ + it('reports a file id absent from this run as not found', async () => { + mocks.getRunFiles.mockResolvedValueOnce(terminalRun([runFile({ id: 'file_other_run' })])) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: workspaceKeyPrincipal, input: input() }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + expect(mocks.downloadFileStream).not.toHaveBeenCalled() + }) + + /** Unknown run and unknown file share one message so neither can be probed. */ + it('uses one message for an unknown run and an unknown file', async () => { + mocks.getRunFiles.mockResolvedValueOnce(null) + const unknownRun = await downloadWorkflowRunFileStream + .execute({ principal: workspaceKeyPrincipal, input: input() }) + .catch((error: Error) => error.message) + + mocks.getRunFiles.mockResolvedValueOnce(terminalRun([])) + const unknownFile = await downloadWorkflowRunFileStream + .execute({ principal: workspaceKeyPrincipal, input: input() }) + .catch((error: Error) => error.message) + + expect(unknownRun).toBe(unknownFile) + }) + + it('reports a run still in flight as a conflict', async () => { + mocks.getRunFiles.mockResolvedValueOnce({ + terminal: false, + workspaceId: 'workspace-1', + filesById: new Map(), + }) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: workspaceKeyPrincipal, input: input() }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.downloadFileStream).not.toHaveBeenCalled() + }) + + /** Storage failure is infrastructure, not a missing resource. */ + it('propagates a storage failure rather than concealing it as not found', async () => { + mocks.downloadFileStream.mockRejectedValueOnce(new Error('s3 unavailable')) + + await expect( + downloadWorkflowRunFileStream.execute({ principal: workspaceKeyPrincipal, input: input() }) + ).rejects.toThrow('s3 unavailable') + }) + + it('falls back to a generic content type when the record has none', async () => { + mocks.getRunFiles.mockResolvedValueOnce(terminalRun([runFile({ type: '' })])) + + const result = await downloadWorkflowRunFileStream.execute({ + principal: workspaceKeyPrincipal, + input: input(), + }) + + expect(result.contentType).toBe('application/octet-stream') + }) +}) diff --git a/apps/sim/lib/workflows/application/download-workflow-run-file.ts b/apps/sim/lib/workflows/application/download-workflow-run-file.ts new file mode 100644 index 00000000000..fbf2c5a62de --- /dev/null +++ b/apps/sim/lib/workflows/application/download-workflow-run-file.ts @@ -0,0 +1,120 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { + type ActiveWorkflowRunApplicationContext, + resolveActiveWorkflowRunApplicationContext, +} from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { getWorkflowRunFiles } from '@/lib/workflows/executor/execution-run-files' +import type { UserFile } from '@/executor/types' + +/** + * One message for every way a file fails to resolve — unknown run, unknown file + * id, a file id belonging to a different run, or an object the retention sweep + * has already collected. Distinguishing them would let a caller probe which run + * ids and file ids exist. + */ +const FILE_NOT_FOUND_MESSAGE = 'File not found' + +export interface DownloadWorkflowRunFileInput { + workflowId: string + runId: string + fileId: string +} + +export interface DownloadWorkflowRunFileResult { + file: UserFile + stream: ReadableStream + contentType: string + contentLength: number +} + +async function executeDownloadWorkflowRunFile({ + context, + input, +}: AuthorizedWorkspaceUseCaseContext< + typeof workflowOperations.downloadRunFile, + DownloadWorkflowRunFileInput, + ActiveWorkflowRunApplicationContext +>): Promise { + const runFiles = await getWorkflowRunFiles({ + workflowId: context.workflowId, + runId: context.runId, + }) + if (!runFiles) throw new OrchestrationError('not_found', FILE_NOT_FOUND_MESSAGE) + + /** + * A run still in flight has no settled output, so there is nothing + * authoritative to address yet. This is retryable rather than a fault. + */ + if (!runFiles.terminal) { + throw new OrchestrationError( + 'conflict', + 'Run has not finished yet; its output files are available once it reaches a terminal state.' + ) + } + + /** + * The caller's `fileId` selects a record; it never supplies one. `key` and + * `context` come off the run's own recording, so the bytes served are always + * bytes this run produced. + */ + const file = runFiles.filesById.get(input.fileId) + if (!file) throw new OrchestrationError('not_found', FILE_NOT_FOUND_MESSAGE) + + /** + * The storage context is inferred from the key rather than read off the + * record's `context` field, so the bucket a read targets is always the one + * the key itself names. This mirrors `getVerifiedStorageContext`, which + * treats a recorded context that disagrees with its key as untrustworthy. + */ + const stream = await downloadFileStream({ + key: file.key, + context: inferContextFromKey(file.key), + }) + + return { + file, + stream: nodeReadableToWebStream(stream), + contentType: file.type || 'application/octet-stream', + contentLength: file.size, + } +} + +/** + * Authorized, audited binary download of one file a run produced. + * + * Authorization is the run's: `resolveActiveWorkflowRunApplicationContext` + * binds the run to its canonical workflow and workspace before the operation's + * role and workspace-key policy are applied, so a workspace-scoped key can only + * ever reach runs inside its own workspace. The file is then resolved against + * that run's recording rather than against any caller-supplied storage address. + */ +export const downloadWorkflowRunFileStream = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.downloadRunFile, + resolveContext: ({ input }: { input: DownloadWorkflowRunFileInput }) => + resolveActiveWorkflowRunApplicationContext({ + runId: input.runId, + assertedWorkflowId: input.workflowId, + }), + execute: executeDownloadWorkflowRunFile, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Downloaded run file "${result.file.name}"`, + metadata: { + fileId: result.file.id, + fileName: result.file.name, + bytes: result.contentLength, + workflowId: context.workflowId, + runId: context.runId, + }, + }), +}) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 5cfae2226f7..3bca7ff58b3 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -274,6 +274,19 @@ export const workflowOperations = { workspaceApiKey: 'allow', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + /** + * Downloading one file a run produced. Separate from `readRun` because it + * hands out bytes and records a `FILE_DOWNLOADED` audit event, which reading + * the run resource does not; it keeps `readRun`'s policy because the resource + * being authorized is still the run — a run file is reachable only through + * the run that recorded it, never as a standalone workspace file. + */ + downloadRunFile: defineWorkspaceOperation({ + id: 'workflows.download_run_file', + minimumRole: 'read', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), cancelRun: defineWorkspaceOperation({ id: 'workflows.runs.cancel', minimumRole: 'write', diff --git a/apps/sim/lib/workflows/executor/execution-run-files.ts b/apps/sim/lib/workflows/executor/execution-run-files.ts new file mode 100644 index 00000000000..8a2cf8886f1 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execution-run-files.ts @@ -0,0 +1,78 @@ +import { db } from '@sim/db' +import { workflowExecutionLogs } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { collectUserFilesById } from '@/lib/core/utils/user-file' +import { materializeExecutionData } from '@/lib/logs/execution/trace-store' +import type { UserFile } from '@/executor/types' + +/** Run states whose recorded output is final and therefore safe to address. */ +const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled']) + +export interface WorkflowRunFilesInput { + workflowId: string + runId: string +} + +export interface WorkflowRunFiles { + /** Whether the run has finished; a live run's output is still changing. */ + terminal: boolean + workspaceId: string | null + /** Files the run's recorded output references, indexed by their file id. */ + filesById: Map +} + +/** + * Reads the authoritative set of files a run produced. + * + * The set is derived from the run's own recorded execution data, materialized + * but deliberately *not* projected for display: the display projection strips + * `key` and `context` (see `USER_FILE_DISPLAY_FIELDS`), which are exactly the + * fields a byte read needs. Because the mapping from file id to storage key is + * rebuilt here from the recording on every call, a caller can name a file only + * by an id the run itself emitted — it can never supply, influence, or probe a + * storage key. + * + * Returns `null` when no log row exists for the run. + */ +export async function getWorkflowRunFiles( + input: WorkflowRunFilesInput +): Promise { + const [logRow] = await db + .select({ + workspaceId: workflowExecutionLogs.workspaceId, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + status: workflowExecutionLogs.status, + executionData: workflowExecutionLogs.executionData, + }) + .from(workflowExecutionLogs) + .where( + and( + eq(workflowExecutionLogs.executionId, input.runId), + eq(workflowExecutionLogs.workflowId, input.workflowId) + ) + ) + .limit(1) + + if (!logRow) return null + + const terminal = TERMINAL_RUN_STATUSES.has(logRow.status) + if (!terminal) { + return { terminal: false, workspaceId: logRow.workspaceId, filesById: new Map() } + } + + const materialized = await materializeExecutionData( + logRow.executionData as Record | null, + { + workspaceId: logRow.workspaceId, + workflowId: logRow.workflowId, + executionId: logRow.executionId, + } + ) + + return { + terminal: true, + workspaceId: logRow.workspaceId, + filesById: collectUserFilesById(materialized), + } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 2cea676d150..15e7fc6832b 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: 1130, - zodRoutes: 1130, + totalRoutes: 1131, + zodRoutes: 1131, nonZodRoutes: 0, } as const From f1376c96a90ee6b54a5573ce26927d1e3ae4db80 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 11:44:10 -0700 Subject: [PATCH 02/55] feat(workflows): one graph-write door, principal-derived audit source, and v2 authoring endpoints Extract replaceWorkflowNormalizedState as the single persistence primitive for a workflow graph replace and route both the internal editor save and the Copilot edit tool through it, so neither can skip state preparation, the row lock, the lastSynced stamp, or custom-tool extraction by choosing a different entry point. Derive the audit source from the acting principal instead of hardcoding 'copilot', then widen workflows.variables.apply_operations and workflows.bulk.move to every principal kind. Add GET/PUT /api/v2/workflows/{id}/state, POST /operations, /duplicate, /restore, PATCH /variables, and POST /api/v2/workflows/move over surface-neutral application use cases; move the edit engine to lib/workflows/editing. --- apps/docs/openapi-v2-billing.json | 2 +- apps/docs/openapi-v2-files-audit.json | 2 +- apps/docs/openapi-v2-knowledge.json | 2 +- apps/docs/openapi-v2-logs.json | 2 +- apps/docs/openapi-v2-resources.json | 2 +- apps/docs/openapi-v2-tables.json | 2 +- apps/docs/openapi-v2-workflows.json | 4637 +++++++++++++---- .../api/v2/workflows/[id]/duplicate/route.ts | 37 + .../api/v2/workflows/[id]/operations/route.ts | 62 + .../api/v2/workflows/[id]/restore/route.ts | 38 + apps/sim/app/api/v2/workflows/[id]/route.ts | 4 +- .../app/api/v2/workflows/[id]/state/route.ts | 58 + .../api/v2/workflows/[id]/variables/route.ts | 33 + apps/sim/app/api/v2/workflows/move/route.ts | 40 + apps/sim/app/api/v2/workflows/route.ts | 10 +- .../v2/__tests__/list-pagination.test.ts | 1 + .../lib/api/contracts/v2/openapi/workflows.ts | 220 +- apps/sim/lib/api/contracts/v2/workflows.ts | 928 +++- .../sim/lib/copilot/sim-sandbox-projection.ts | 10 - .../server/workflow/edit-workflow/index.ts | 435 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 7 +- apps/sim/lib/core/application/audit-source.ts | 17 + apps/sim/lib/core/application/forbidden.ts | 4 + apps/sim/lib/core/application/index.ts | 1 + apps/sim/lib/workflows/api/route-policies.ts | 20 + .../application/apply-workflow-operations.ts | 421 ++ apps/sim/lib/workflows/application/context.ts | 35 + .../application/duplicate-workflow.ts | 41 +- .../workflows/application/list-workflows.ts | 2 + .../application/move-workflows-bulk.ts | 19 +- .../lib/workflows/application/operations.ts | 26 +- .../application/read-workflow-graph.ts | 58 + .../application/replace-workflow-state.ts | 125 + .../workflows/application/restore-workflow.ts | 93 + .../application/update-workflow-content.ts | 128 +- .../application/workflow-mutability.ts | 14 + .../lib/workflows/editing/block-enablement.ts | 128 + .../editing}/builders.test.ts | 2 +- .../editing}/builders.ts | 0 .../editing}/engine.ts | 0 .../editing}/lint.test.ts | 0 .../editing}/lint.ts | 0 .../editing}/operations.test.ts | 0 .../editing}/operations.ts | 0 .../editing/sandbox-projection.test.ts} | 2 +- .../workflows/editing/sandbox-projection.ts | 9 + .../editing}/selector-validator.test.ts | 0 .../editing}/selector-validator.ts | 0 .../editing}/types.ts | 47 +- .../editing}/validation.test.ts | 2 +- .../editing}/validation.ts | 2 +- .../lib/workflows/persistence/duplicate.ts | 5 + .../persistence/replace-normalized-state.ts | 137 + .../persistence/save-normalized-state.ts | 130 +- apps/sim/lib/workflows/queries.ts | 6 +- bun.lock | 4 +- scripts/check-api-validation-contracts.ts | 4 +- 57 files changed, 6296 insertions(+), 1718 deletions(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/duplicate/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/operations/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/restore/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/state/route.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/variables/route.ts create mode 100644 apps/sim/app/api/v2/workflows/move/route.ts create mode 100644 apps/sim/lib/core/application/audit-source.ts create mode 100644 apps/sim/lib/workflows/application/apply-workflow-operations.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-graph.ts create mode 100644 apps/sim/lib/workflows/application/replace-workflow-state.ts create mode 100644 apps/sim/lib/workflows/application/restore-workflow.ts create mode 100644 apps/sim/lib/workflows/application/workflow-mutability.ts create mode 100644 apps/sim/lib/workflows/editing/block-enablement.ts rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/builders.test.ts (99%) rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/builders.ts (100%) rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/engine.ts (100%) rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/lint.test.ts (100%) rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/lint.ts (100%) rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/operations.test.ts (100%) rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/operations.ts (100%) rename apps/sim/lib/{copilot/sim-sandbox-projection.test.ts => workflows/editing/sandbox-projection.test.ts} (88%) create mode 100644 apps/sim/lib/workflows/editing/sandbox-projection.ts rename apps/sim/lib/{copilot/validation => workflows/editing}/selector-validator.test.ts (100%) rename apps/sim/lib/{copilot/validation => workflows/editing}/selector-validator.ts (100%) rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/types.ts (81%) rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/validation.test.ts (99%) rename apps/sim/lib/{copilot/tools/server/workflow/edit-workflow => workflows/editing}/validation.ts (99%) create mode 100644 apps/sim/lib/workflows/persistence/replace-normalized-state.ts diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index c0e6b736aa1..755548dedca 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -452,7 +452,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index f5fa23ada8b..3caeb6bed3a 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2297,7 +2297,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 401eb99a249..56955ca41b4 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2250,7 +2250,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index af7bfe5e2dd..d57aa838c14 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -574,7 +574,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index f34b36a578e..732c1fb1536 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2597,7 +2597,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 877c8b329ce..fd9485d4e71 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -4011,7 +4011,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 6d43438c88b..021bceca8f2 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -40,7 +40,7 @@ "get": { "operationId": "listWorkflows", "summary": "List Workflows", - "description": "List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List workflows in a workspace with lifecycle scope, folder and deployment filters, search, sorting, and opaque cursor pagination. `scope` defaults to `active`; pass `archived` to list workflows a `DELETE` archived. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -55,6 +55,18 @@ "description": "Workspace whose workflows should be listed." } }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "schema": { + "default": "active", + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "type": "string", + "enum": ["active", "archived"] + } + }, { "name": "folderPath", "in": "query", @@ -187,7 +199,7 @@ "post": { "operationId": "createWorkflowV2", "summary": "Create Workflow", - "description": "Create a workflow in a workspace root or canonical workflow folder. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Create a workflow in a workspace root or canonical workflow folder. The response carries the blocks the platform seeded the workflow with, so the start block's id is available without a second request — attach edges to it directly. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "requestBody": { "required": true, @@ -255,11 +267,11 @@ } } }, - "/api/v2/workflows/{id}": { + "/api/v2/workflows/{id}/state": { "get": { - "operationId": "getWorkflow", - "summary": "Get Workflow", - "description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace folder tree over 10,000 folders is a `413`.", + "operationId": "getWorkflowState", + "summary": "Get Workflow State", + "description": "Get the editable draft graph of a workflow: blocks, edges, the loop and parallel containers derived from them, and variables. This is the pollable read — it records no audit event, and `HEAD` mirrors `GET`. The payload is **unsanitized**: it carries workspace-scoped `credentialId`, `knowledgeBaseId`, and `tableId` values verbatim, so it is not portable to another workspace. Use `GET /workflows/{id}/export` for a portable, sanitized copy — and note that export is not a read-modify-write source, because sanitizing it drops every credential binding. Unknown members are stripped, so what this returns is exactly the set of keys `PUT /workflows/{id}/state` accepts.", "tags": ["Workflows"], "parameters": [ { @@ -277,7 +289,7 @@ ], "responses": { "200": { - "description": "The requested workflow.", + "description": "The workflow draft graph.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -292,7 +304,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowDetailResponse" + "$ref": "#/components/schemas/WorkflowStateResponse" } } } @@ -323,10 +335,10 @@ } } }, - "patch": { - "operationId": "updateWorkflowV2", - "summary": "Update Workflow", - "description": "Rename, describe, or move a workflow to a canonical folder path. A workspace folder tree over 10,000 folders is a `413`.", + "put": { + "operationId": "replaceWorkflowState", + "summary": "Replace Workflow State", + "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state and no conflict detection.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{id}/deploy` publishes the draft.", "tags": ["Workflows"], "parameters": [ { @@ -344,18 +356,18 @@ ], "requestBody": { "required": true, - "description": "Fields to update on an existing workflow.", + "description": "A complete replacement draft graph for a workflow.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateWorkflowRequest" + "$ref": "#/components/schemas/ReplaceWorkflowStateRequest" } } } }, "responses": { "200": { - "description": "The updated workflow.", + "description": "The draft graph was replaced.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -370,7 +382,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateWorkflowResponse" + "$ref": "#/components/schemas/ReplaceWorkflowStateResponse" } } } @@ -406,11 +418,13 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "deleteWorkflowV2", - "summary": "Delete Workflow", - "description": "Permanently delete a workflow and its associated mutable state.", + } + }, + "/api/v2/workflows/{id}/operations": { + "post": { + "operationId": "applyWorkflowOperations", + "summary": "Apply Workflow Operations", + "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"` and the same `skipped` array, having persisted nothing.\n\nAs with `PUT /workflows/{id}/state`, this changes only the draft; deploy to publish it.", "tags": ["Workflows"], "parameters": [ { @@ -426,9 +440,20 @@ } } ], + "requestBody": { + "required": true, + "description": "A batch of semantic edits against a workflow graph.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyWorkflowOperationsRequest" + } + } + } + }, "responses": { "200": { - "description": "The workflow was deleted.", + "description": "The batch was applied.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -443,7 +468,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteWorkflowResponse" + "$ref": "#/components/schemas/ApplyWorkflowOperationsResponse" } } } @@ -460,6 +485,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "423": { "$ref": "#/components/responses/Locked" }, @@ -475,11 +506,11 @@ } } }, - "/api/v2/workflows/{id}/versions": { - "get": { - "operationId": "listWorkflowVersionsV2", - "summary": "List Workflow Versions", - "description": "List immutable deployment versions of a workflow, newest first.", + "/api/v2/workflows/{id}/variables": { + "patch": { + "operationId": "applyWorkflowVariables", + "summary": "Update Workflow Variables", + "description": "Add, edit, and delete a workflow’s variables. Operations are matched by variable `name` and applied in order; a batch that changes nothing answers `200` with `changed: false`. Values are coerced to the declared `type`, and a value that cannot be coerced is stored as supplied. Read the current set from `variables` on `GET /workflows/{id}`.", "tags": ["Workflows"], "parameters": [ { @@ -493,35 +524,22 @@ "description": "Unique workflow identifier.", "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum deployment versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum deployment versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } } ], + "requestBody": { + "required": true, + "description": "Additions, edits, and deletions against a workflow’s variables.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyWorkflowVariablesRequest" + } + } + } + }, "responses": { "200": { - "description": "A page of deployment versions.", + "description": "The variable set after the batch.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -536,7 +554,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowVersionListResponse" + "$ref": "#/components/schemas/ApplyWorkflowVariablesResponse" } } } @@ -553,6 +571,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -565,11 +592,11 @@ } } }, - "/api/v2/workflows/{id}/versions/{version}": { - "get": { - "operationId": "getWorkflowVersionV2", - "summary": "Get Workflow Version", - "description": "Get an immutable deployment version and its pinned workflow graph snapshot.", + "/api/v2/workflows/{id}/duplicate": { + "post": { + "operationId": "duplicateWorkflow", + "summary": "Duplicate Workflow", + "description": "Copy a workflow, including its blocks, edges, subflows, and variables, into the same workspace. Omitting `name` reuses the source name; a collision inside the destination folder is deduplicated rather than refused. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -580,26 +607,25 @@ "schema": { "type": "string", "minLength": 1, - "description": "Unique workflow identifier." - } - }, - { - "name": "version", - "in": "path", - "required": true, - "description": "Numeric deployment version.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Numeric deployment version.", - "examples": [3] + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } } ], + "requestBody": { + "required": true, + "description": "Optional name and destination folder for the copy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DuplicateWorkflowRequest" + } + } + } + }, "responses": { - "200": { - "description": "The requested deployment version.", + "201": { + "description": "The created copy.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -614,7 +640,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowVersionDetailResponse" + "$ref": "#/components/schemas/DuplicateWorkflowResponse" } } } @@ -631,6 +657,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -643,11 +678,11 @@ } } }, - "/api/v2/workflows/{id}/deployment": { - "get": { - "operationId": "getWorkflowDeployment", - "summary": "Get Workflow Deployment", - "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment`.", + "/api/v2/workflows/{id}/restore": { + "post": { + "operationId": "restoreWorkflow", + "summary": "Restore Workflow", + "description": "Bring an archived workflow back, along with the schedules, webhooks, MCP tools, and chats that were archived with it. A workflow that is not archived answers `409`. A workflow whose folder was archived is restored to the workspace root. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -665,7 +700,7 @@ ], "responses": { "200": { - "description": "The current deployment state.", + "description": "The restored workflow.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -680,7 +715,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowDeploymentResponse" + "$ref": "#/components/schemas/RestoreWorkflowResponse" } } } @@ -697,6 +732,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -709,40 +750,26 @@ } } }, - "/api/v2/workflows/{id}/deploy": { + "/api/v2/workflows/move": { "post": { - "operationId": "deployWorkflow", - "summary": "Deploy Workflow", - "description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key.", + "operationId": "moveWorkflows", + "summary": "Move Workflows", + "description": "Relocate up to 100 workflows into one folder. Explicitly best-effort: each workflow moves in its own transaction, and one that is absent from the workspace, archived, or locked lands in `failed` while the rest still move. Duplicate ids are collapsed. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "description": "Unique workflow identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - } - } - ], "requestBody": { - "required": false, - "description": "Optional metadata for the new deployment version.", + "required": true, + "description": "Workflows to relocate and the folder to relocate them into.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeployWorkflowRequest" + "$ref": "#/components/schemas/MoveWorkflowsRequest" } } } }, "responses": { "200": { - "description": "The accepted deployment attempt.", + "description": "Which workflows moved and which did not.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -757,7 +784,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeployWorkflowResponse" + "$ref": "#/components/schemas/MoveWorkflowsResponse" } } } @@ -793,11 +820,13 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "undeployWorkflow", - "summary": "Undeploy Workflow", - "description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key.", + } + }, + "/api/v2/workflows/{id}": { + "get": { + "operationId": "getWorkflow", + "summary": "Get Workflow", + "description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -815,7 +844,7 @@ ], "responses": { "200": { - "description": "The workflow was undeployed.", + "description": "The requested workflow.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -830,7 +859,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UndeployWorkflowResponse" + "$ref": "#/components/schemas/WorkflowDetailResponse" } } } @@ -847,8 +876,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "423": { - "$ref": "#/components/responses/Locked" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -860,13 +889,11 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workflows/{id}/rollback": { - "post": { - "operationId": "rollbackWorkflow", - "summary": "Rollback Workflow", - "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key is rejected with `403`; use a personal API key.", + }, + "patch": { + "operationId": "updateWorkflowV2", + "summary": "Update Workflow", + "description": "Rename, describe, or move a workflow to a canonical folder path. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], "parameters": [ { @@ -883,19 +910,19 @@ } ], "requestBody": { - "required": false, - "description": "Optional deployment version to reactivate.", + "required": true, + "description": "Fields to update on an existing workflow.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RollbackWorkflowRequest" + "$ref": "#/components/schemas/UpdateWorkflowRequest" } } } }, "responses": { "200": { - "description": "The accepted rollback attempt.", + "description": "The updated workflow.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -910,7 +937,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RollbackWorkflowResponse" + "$ref": "#/components/schemas/UpdateWorkflowResponse" } } } @@ -946,13 +973,11 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workflows/{id}/export": { - "get": { - "operationId": "exportWorkflow", - "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. A workspace folder tree over 10,000 folders is a `413`.", + }, + "delete": { + "operationId": "deleteWorkflowV2", + "summary": "Delete Workflow", + "description": "Archive a workflow. Despite the verb, this is not an erasure: the workflow, and the schedules, webhooks, MCP tools, and chats attached to it, are stamped archived and stop running, and `POST /workflows/{id}/restore` brings all of them back. An archived workflow disappears from the default list and is reachable with `scope=archived`. The `deleted` field is retained for shipped clients; `archived` states what actually happened.", "tags": ["Workflows"], "parameters": [ { @@ -970,7 +995,7 @@ ], "responses": { "200": { - "description": "The workflow export payload.", + "description": "The workflow was archived.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -985,7 +1010,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExportWorkflowResponse" + "$ref": "#/components/schemas/DeleteWorkflowResponse" } } } @@ -1002,8 +1027,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" + "423": { + "$ref": "#/components/responses/Locked" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -1017,26 +1042,53 @@ } } }, - "/api/v2/workflows/import": { - "post": { - "operationId": "importWorkflow", - "summary": "Import Workflow", - "description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace folder tree over 10,000 folders is a `413`.", + "/api/v2/workflows/{id}/versions": { + "get": { + "operationId": "listWorkflowVersionsV2", + "summary": "List Workflow Versions", + "description": "List immutable deployment versions of a workflow, newest first.", "tags": ["Workflows"], - "requestBody": { - "required": true, - "description": "Portable workflow data and destination metadata for an import.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ImportWorkflowRequest" - } - } - } - }, + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum deployment versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum deployment versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], "responses": { - "201": { - "description": "The imported workflow.", + "200": { + "description": "A page of deployment versions.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1051,7 +1103,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportWorkflowResponse" + "$ref": "#/components/schemas/WorkflowVersionListResponse" } } } @@ -1068,15 +1120,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "423": { - "$ref": "#/components/responses/Locked" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1089,18 +1132,12 @@ } } }, - "/api/v2/workflows/{id}/execute": { - "post": { - "operationId": "executeWorkflowV2", - "summary": "Execute Workflow", - "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", + "/api/v2/workflows/{id}/versions/{version}": { + "get": { + "operationId": "getWorkflowVersionV2", + "summary": "Get Workflow Version", + "description": "Get an immutable deployment version and its pinned workflow graph snapshot.", "tags": ["Workflows"], - "security": [ - { - "apiKey": [] - }, - {} - ], "parameters": [ { "name": "id", @@ -1110,53 +1147,27 @@ "schema": { "type": "string", "minLength": 1, - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - } - }, - { - "name": "x-run-id", - "in": "header", - "required": false, - "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", - "schema": { - "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "examples": ["run_8f14e45f-ceea-467f-a"] + "description": "Unique workflow identifier." } }, { - "name": "x-sim-via", - "in": "header", - "required": false, - "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", + "name": "version", + "in": "path", + "required": true, + "description": "Numeric deployment version.", "schema": { - "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", - "type": "string" + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Numeric deployment version.", + "examples": [3] } } ], - "requestBody": { - "required": true, - "description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExecuteWorkflowRequest" - } - } - } - }, "responses": { "200": { - "description": "A synchronous run result or Server-Sent Event stream.", + "description": "The requested deployment version.", "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1170,22 +1181,59 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExecuteWorkflowSyncResponse" - } - }, - "text/event-stream": { - "schema": { - "type": "string" + "$ref": "#/components/schemas/WorkflowVersionDetailResponse" } } } }, - "202": { - "description": "The asynchronous run was queued.", + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/{id}/deployment": { + "get": { + "operationId": "getWorkflowDeployment", + "summary": "Get Workflow Deployment", + "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment`.", + "tags": ["Workflows"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + } + } + ], + "responses": { + "200": { + "description": "The current deployment state.", "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1199,7 +1247,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ExecuteWorkflowQueuedResponse" + "$ref": "#/components/schemas/WorkflowDeploymentResponse" } } } @@ -1210,27 +1258,15 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/RunIdConflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, - "499": { - "$ref": "#/components/responses/ClientClosedRequest" - }, "500": { "$ref": "#/components/responses/InternalError" }, @@ -1240,12 +1276,12 @@ } } }, - "/api/v2/workflows/{id}/runs": { - "get": { - "operationId": "listWorkflowRunsV2", - "summary": "List Workflow Runs", - "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", - "tags": ["Workflow Runs"], + "/api/v2/workflows/{id}/deploy": { + "post": { + "operationId": "deployWorkflow", + "summary": "Deploy Workflow", + "description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Workflows"], "parameters": [ { "name": "id", @@ -1258,110 +1294,39 @@ "description": "Unique workflow identifier.", "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } - }, - { - "name": "status", - "in": "query", - "required": false, - "description": "Filter by run status.", - "schema": { - "description": "Filter by run status.", - "type": "string", - "enum": ["pending", "running", "completed", "failed", "cancelled", "paused"] + } + ], + "requestBody": { + "required": false, + "description": "Optional metadata for the new deployment version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployWorkflowRequest" + } } - }, - { - "name": "trigger", - "in": "query", - "required": false, - "description": "Filter by trigger type.", - "schema": { - "description": "Filter by trigger type.", - "type": "string", - "minLength": 1 - } - }, - { - "name": "startDate", - "in": "query", - "required": false, - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." - } - }, - { - "name": "endDate", - "in": "query", - "required": false, - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum workflow runs to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum workflow runs to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } - }, - { - "name": "order", - "in": "query", - "required": false, - "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", - "schema": { - "default": "desc", - "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", - "type": "string", - "enum": ["asc", "desc"] - } - } - ], - "responses": { - "200": { - "description": "A page of workflow runs.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkflowRunListResponse" - } - } + } + }, + "responses": { + "200": { + "description": "The accepted deployment attempt.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeployWorkflowResponse" + } + } } }, "400": { @@ -1376,6 +1341,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1386,14 +1360,12 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/workflows/{id}/runs/{runId}": { - "get": { - "operationId": "getWorkflowRunV2", - "summary": "Get Workflow Run", - "description": "Get current workflow run state, optionally including final and block outputs.", - "tags": ["Workflow Runs"], + }, + "delete": { + "operationId": "undeployWorkflow", + "summary": "Undeploy Workflow", + "description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Workflows"], "parameters": [ { "name": "id", @@ -1403,47 +1375,14 @@ "schema": { "type": "string", "minLength": 1, - "description": "Unique workflow identifier." - } - }, - { - "name": "runId", - "in": "path", - "required": true, - "description": "Unique workflow run identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] - } - }, - { - "name": "includeOutput", - "in": "query", - "required": false, - "description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.", - "schema": { - "description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.", - "type": "boolean" - } - }, - { - "name": "selectedOutputs", - "in": "query", - "required": false, - "description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.", - "schema": { - "description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.", - "type": "string" + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } } ], "responses": { "200": { - "description": "The workflow run status.", + "description": "The workflow was undeployed.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1458,7 +1397,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowRunStatusResponse" + "$ref": "#/components/schemas/UndeployWorkflowResponse" } } } @@ -1475,8 +1414,8 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" + "423": { + "$ref": "#/components/responses/Locked" }, "429": { "$ref": "#/components/responses/RateLimited" @@ -1490,12 +1429,12 @@ } } }, - "/api/v2/workflows/{id}/runs/{runId}/resume": { + "/api/v2/workflows/{id}/rollback": { "post": { - "operationId": "resumeWorkflowRunV2", - "summary": "Resume Workflow Run", - "description": "Resume one human-in-the-loop pause context. The resumed attempt receives a new run identifier and may complete synchronously or return a queue receipt.", - "tags": ["Workflow Runs"], + "operationId": "rollbackWorkflow", + "summary": "Rollback Workflow", + "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Workflows"], "parameters": [ { "name": "id", @@ -1505,66 +1444,26 @@ "schema": { "type": "string", "minLength": 1, - "description": "Unique workflow identifier." - } - }, - { - "name": "runId", - "in": "path", - "required": true, - "description": "Unique workflow run identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } } ], "requestBody": { - "required": true, - "description": "Pause context and optional input used to resume a workflow run.", + "required": false, + "description": "Optional deployment version to reactivate.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResumeWorkflowRequest" + "$ref": "#/components/schemas/RollbackWorkflowRequest" } } } }, "responses": { "200": { - "description": "The resumed workflow attempt completed synchronously.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - }, - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResumeWorkflowSyncResponse" - } - } - } - }, - "202": { - "description": "The resumed workflow attempt was queued.", + "description": "The accepted rollback attempt.", "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1578,7 +1477,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ResumeWorkflowQueuedResponse" + "$ref": "#/components/schemas/RollbackWorkflowResponse" } } } @@ -1589,9 +1488,6 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, - "402": { - "$ref": "#/components/responses/UsageLimitExceeded" - }, "403": { "$ref": "#/components/responses/Forbidden" }, @@ -1604,6 +1500,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1616,12 +1515,12 @@ } } }, - "/api/v2/workflows/{id}/runs/{runId}/cancel": { - "post": { - "operationId": "cancelRunV2", - "summary": "Cancel Workflow Run", - "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.", - "tags": ["Workflow Runs"], + "/api/v2/workflows/{id}/export": { + "get": { + "operationId": "exportWorkflow", + "summary": "Export Workflow", + "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Workflows"], "parameters": [ { "name": "id", @@ -1631,27 +1530,14 @@ "schema": { "type": "string", "minLength": 1, - "description": "Unique workflow identifier." - } - }, - { - "name": "runId", - "in": "path", - "required": true, - "description": "Unique workflow run identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } } ], "responses": { "200": { - "description": "The cancellation outcome.", + "description": "The workflow export payload.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1666,7 +1552,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CancelWorkflowRunResponse" + "$ref": "#/components/schemas/ExportWorkflowResponse" } } } @@ -1683,9 +1569,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1698,75 +1584,26 @@ } } }, - "/api/v2/workflows/folders": { - "get": { - "operationId": "listWorkflowsFolders", - "summary": "List Workflow Folders", - "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + "/api/v2/workflows/import": { + "post": { + "operationId": "importWorkflow", + "summary": "Import Workflow", + "description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Workflows"], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace whose folders should be listed.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace whose folders should be listed." - } - }, - { - "name": "parentPath", - "in": "query", - "required": false, - "description": "Restrict results to direct children of this parent path.", - "schema": { - "description": "Restrict results to direct children of this parent path.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the folder name.", - "schema": { - "description": "Case-insensitive substring match against the folder name.", - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "name", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "createdAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] + "requestBody": { + "required": true, + "description": "Portable workflow data and destination metadata for an import.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportWorkflowRequest" + } } } - ], + }, "responses": { - "200": { - "description": "A list of workflow folders.", + "201": { + "description": "The imported workflow.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1781,7 +1618,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorkflowFolderListResponse" + "$ref": "#/components/schemas/ImportWorkflowResponse" } } } @@ -1798,9 +1635,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1811,27 +1654,76 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, + } + }, + "/api/v2/workflows/{id}/execute": { "post": { - "operationId": "createWorkflowsFolder", - "summary": "Create Workflow Folder", - "description": "Create a canonical workflow folder in a workspace. A workspace folder tree over 10,000 folders is a `413`.", + "operationId": "executeWorkflowV2", + "summary": "Execute Workflow", + "description": "Execute a deployed workflow synchronously, asynchronously, or as Server-Sent Events. Public workflows permit anonymous synchronous and streaming execution; asynchronous execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "tags": ["Workflows"], + "security": [ + { + "apiKey": [] + }, + {} + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + } + }, + { + "name": "x-run-id", + "in": "header", + "required": false, + "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", + "schema": { + "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "examples": ["run_8f14e45f-ceea-467f-a"] + } + }, + { + "name": "x-sim-via", + "in": "header", + "required": false, + "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", + "schema": { + "description": "Comma-separated workflow identifiers naming the workflow-to-workflow call chain that led to this request. Each hop appends its own workflow id, and Sim sets it automatically; supply it yourself only when relaying an existing chain. A chain at the maximum depth is rejected with `409` and `error.details.code: \"CALL_CHAIN_DEPTH_EXCEEDED\"`.", + "type": "string" + } + } + ], "requestBody": { "required": true, - "description": "Workspace and canonical path for a new workflow folder.", + "description": "Input and execution-mode options for a deployed workflow. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkflowFolderRequest" + "$ref": "#/components/schemas/ExecuteWorkflowRequest" } } } }, "responses": { - "201": { - "description": "The created workflow folder.", + "200": { + "description": "A synchronous run result or Server-Sent Event stream.", "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1845,63 +1737,22 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateWorkflowFolderResponse" + "$ref": "#/components/schemas/ExecuteWorkflowSyncResponse" + } + }, + "text/event-stream": { + "schema": { + "type": "string" } } } }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "423": { - "$ref": "#/components/responses/Locked" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - }, - "patch": { - "operationId": "relocateWorkflowsFolder", - "summary": "Rename or Move Workflow Folder", - "description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace folder tree over 10,000 folders is a `413`.", - "tags": ["Workflows"], - "requestBody": { - "required": true, - "description": "Current and destination paths for a workflow folder.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RelocateWorkflowFolderRequest" - } - } - } - }, - "responses": { - "200": { - "description": "The relocated workflow folder.", + "202": { + "description": "The asynchronous run was queued.", "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1915,7 +1766,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RelocateWorkflowFolderResponse" + "$ref": "#/components/schemas/ExecuteWorkflowQueuedResponse" } } } @@ -1926,6 +1777,9 @@ "401": { "$ref": "#/components/responses/Unauthorized" }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, "403": { "$ref": "#/components/responses/Forbidden" }, @@ -1933,17 +1787,17 @@ "$ref": "#/components/responses/NotFound" }, "409": { - "$ref": "#/components/responses/Conflict" + "$ref": "#/components/responses/RunIdConflict" }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "423": { - "$ref": "#/components/responses/Locked" - }, "429": { "$ref": "#/components/responses/RateLimited" }, + "499": { + "$ref": "#/components/responses/ClientClosedRequest" + }, "500": { "$ref": "#/components/responses/InternalError" }, @@ -1951,64 +1805,113 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "deleteWorkflowsFolder", - "summary": "Delete Workflow Folder", - "description": "Delete a workflow folder, optionally including its descendants and workflows.", - "tags": ["Workflows"], + } + }, + "/api/v2/workflows/{id}/runs": { + "get": { + "operationId": "listWorkflowRunsV2", + "summary": "List Workflow Runs", + "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", + "tags": ["Workflow Runs"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "id", + "in": "path", "required": true, - "description": "Workspace containing the folder.", + "description": "Unique workflow identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace containing the folder." - } + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + } }, { - "name": "path", + "name": "status", "in": "query", - "required": true, - "description": "Path of the folder to delete.", + "required": false, + "description": "Filter by run status.", "schema": { - "description": "Path of the folder to delete.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "description": "Filter by run status.", + "type": "string", + "enum": ["pending", "running", "completed", "failed", "cancelled", "paused"] } }, { - "name": "recursive", + "name": "trigger", "in": "query", "required": false, - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "description": "Filter by trigger type.", "schema": { - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "default": "false", - "type": "string" + "description": "Filter by trigger type.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum workflow runs to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum workflow runs to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "order", + "in": "query", + "required": false, + "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", + "schema": { + "default": "desc", + "description": "Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects.", + "type": "string", + "enum": ["asc", "desc"] } } ], "responses": { "200": { - "description": "The workflow folder was deleted.", + "description": "A page of workflow runs.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2023,7 +1926,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteWorkflowFolderResponse" + "$ref": "#/components/schemas/WorkflowRunListResponse" } } } @@ -2040,15 +1943,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "423": { - "$ref": "#/components/responses/Locked" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2060,494 +1954,3021 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } - }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { + "/api/v2/workflows/{id}/runs/{runId}": { + "get": { + "operationId": "getWorkflowRunV2", + "summary": "Get Workflow Run", + "description": "Get current workflow run state, optionally including final and block outputs.", + "tags": ["Workflow Runs"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier." } - } - } - }, - "Unauthorized": { - "description": "The API key is missing or invalid.", - "content": { - "application/json": { + }, + { + "name": "runId", + "in": "path", + "required": true, + "description": "Unique workflow run identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "API key required" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] } - } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { + }, + { + "name": "includeOutput", + "in": "query", + "required": false, + "description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "USAGE_LIMIT_EXCEEDED", - "message": "Usage limit exceeded. Please upgrade your plan to continue." - } + "description": "Include the final workflow output when true. It does not gate `blockOutputs`, which `selectedOutputs` selects on its own.", + "type": "boolean" } - } - } - }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" - } - } - } - } - } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { + }, + { + "name": "selectedOutputs", + "in": "query", + "required": false, + "description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "description": "Comma-separated block output references to include, as `blockId` or `blockId.path`. Block *names* are not resolved here — unlike the execute request, this resource reads a recorded run and matches ids only, so a name selects nothing and yields an empty `blockOutputs`.", + "type": "string" } } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Webhook path already in use" + ], + "responses": { + "200": { + "description": "The workflow run status.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - } - } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Run ID has already been used", - "details": { - "code": "RUN_ID_CONFLICT", - "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowRunStatusResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { + } + }, + "/api/v2/workflows/{id}/runs/{runId}/resume": { + "post": { + "operationId": "resumeWorkflowRunV2", + "summary": "Resume Workflow Run", + "description": "Resume one human-in-the-loop pause context. The resumed attempt receives a new run identifier and may complete synchronously or return a queue receipt.", + "tags": ["Workflow Runs"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier." } - } - } - }, - "Locked": { - "description": "The resource is locked and cannot be modified.", - "content": { - "application/json": { + }, + { + "name": "runId", + "in": "path", + "required": true, + "description": "Unique workflow run identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "LOCKED", - "message": "Workflow is locked" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] } } - } - }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + ], + "requestBody": { + "required": true, + "description": "Pause context and optional input used to resume a workflow run.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResumeWorkflowRequest" + } + } } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "responses": { + "200": { + "description": "The resumed workflow attempt completed synchronously.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResumeWorkflowSyncResponse" } } } - } - } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + }, + "202": { + "description": "The resumed workflow attempt was queued.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "example": { - "error": { - "code": "CLIENT_CLOSED_REQUEST", - "message": "Client cancelled request", - "details": { - "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResumeWorkflowQueuedResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "402": { + "$ref": "#/components/responses/UsageLimitExceeded" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/{id}/runs/{runId}/cancel": { + "post": { + "operationId": "cancelRunV2", + "summary": "Cancel Workflow Run", + "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.", + "tags": ["Workflow Runs"], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Unique workflow identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Unique workflow identifier." + } + }, + { + "name": "runId", + "in": "path", + "required": true, + "description": "Unique workflow run identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + } + } + ], + "responses": { + "200": { + "description": "The cancellation outcome.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CancelWorkflowRunResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/folders": { + "get": { + "operationId": "listWorkflowsFolders", + "summary": "List Workflow Folders", + "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Workflows"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose folders should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose folders should be listed." + } + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Restrict results to direct children of this parent path.", + "schema": { + "description": "Restrict results to direct children of this parent path.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the folder name.", + "schema": { + "description": "Case-insensitive substring match against the folder name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + } + ], + "responses": { + "200": { + "description": "A list of workflow folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowFolderListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "createWorkflowsFolder", + "summary": "Create Workflow Folder", + "description": "Create a canonical workflow folder in a workspace. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "description": "Workspace and canonical path for a new workflow folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkflowFolderRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created workflow folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkflowFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "patch": { + "operationId": "relocateWorkflowsFolder", + "summary": "Rename or Move Workflow Folder", + "description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace folder tree over 10,000 folders is a `413`.", + "tags": ["Workflows"], + "requestBody": { + "required": true, + "description": "Current and destination paths for a workflow folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelocateWorkflowFolderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The relocated workflow folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelocateWorkflowFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteWorkflowsFolder", + "summary": "Delete Workflow Folder", + "description": "Delete a workflow folder, optionally including its descendants and workflows.", + "tags": ["Workflows"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace containing the folder.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the folder." + } + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Path of the folder to delete.", + "schema": { + "description": "Path of the folder to delete.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "default": "false", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The workflow folder was deleted.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteWorkflowFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } + } + } + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Webhook path already in use" + } + } + } + } + }, + "RunIdConflict": { + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Run ID has already been used", + "details": { + "code": "RUN_ID_CONFLICT", + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "Locked": { + "description": "The resource is locked and cannot be modified.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked" + } + } + } + } + }, + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "ClientClosedRequest": { + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CLIENT_CLOSED_REQUEST", + "message": "Client cancelled request", + "details": { + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy." + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "WorkflowListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + } + }, + "required": [ + "id", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow summary", + "description": "Summary of a workflow and its deployment and run state." + }, + "WorkflowListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Workflow list response", + "description": "A cursor-paginated page of workflow summaries.", + "examples": [ + { + "data": [ + { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "SeededWorkflowBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Block identifier." + }, + "type": { + "type": "string", + "description": "Registered block type." + }, + "name": { + "type": "string", + "description": "Block display name." + } + }, + "required": ["id", "type", "name"], + "additionalProperties": false, + "title": "Seeded workflow block", + "description": "A block the platform placed in a newly created workflow." + }, + "CreateWorkflowResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + }, + "blocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeededWorkflowBlock" + }, + "description": "Blocks seeded into the new workflow. Contains the start block; attach edges to its `id`." + } + }, + "required": [ + "id", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt", + "blocks" + ], + "additionalProperties": false, + "title": "Create workflow result", + "description": "The created workflow and the blocks it was seeded with." + }, + "CreateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/CreateWorkflowResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create workflow response", + "description": "The created workflow and the blocks it was seeded with.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z", + "blocks": [ + { + "id": "start-1", + "type": "starter", + "name": "Start" + } + ] + } + } + ] + }, + "CreateWorkflowRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the workflow." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Workflow name." + }, + "description": { + "description": "Optional workflow description.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] + }, + "folderPath": { + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["workspaceId", "name"], + "additionalProperties": false, + "title": "Create workflow request", + "description": "Name, description, workspace, and optional folder for a new workflow." + }, + "WorkflowBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Block identifier, unique within the workflow." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Block display name; must be unique within the workflow." + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Canvas x coordinate." + }, + "y": { + "type": "number", + "description": "Canvas y coordinate." + } + }, + "required": ["x", "y"], + "additionalProperties": false, + "description": "Canvas coordinates of a block." + }, + "subBlocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Sub-block identifier." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Sub-block input type." + }, + "value": { + "description": "Configured value; shape depends on the sub-block type." + } + }, + "required": ["id", "type", "value"], + "additionalProperties": false, + "description": "One configurable input on a block." + }, + "description": "Configured inputs keyed by sub-block id." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Declared shape of one output; depends on the block type." + }, + "description": "Declared output shape keyed by output name." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block runs." + }, + "horizontalHandles": { + "description": "Whether edge handles render horizontally.", + "type": "boolean" + }, + "height": { + "description": "Rendered block height.", + "type": "number" + }, + "advancedMode": { + "description": "Whether the block is edited in advanced mode.", + "type": "boolean" + }, + "errorEnabled": { + "description": "Whether the block exposes an error branch.", + "type": "boolean" + }, + "retry": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the block retries on failure." + }, + "maxTries": { + "type": "integer", + "minimum": 2, + "maximum": 5, + "description": "Total attempts, including the first." + }, + "waitBetweenTriesMs": { + "type": "integer", + "minimum": 0, + "maximum": 5000, + "description": "Delay between attempts, in milliseconds." + } + }, + "required": ["enabled", "maxTries", "waitBetweenTriesMs"], + "additionalProperties": false, + "description": "Per-block retry configuration." + }, + "triggerMode": { + "description": "Whether the block acts as the workflow trigger.", + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "parentId": { + "description": "Identifier of the containing loop or parallel.", + "type": "string" + }, + "extent": { + "description": "Constrains the block to its parent bounds.", + "type": "string", + "const": "parent" + }, + "width": { + "description": "Rendered container width.", + "type": "number" + }, + "height": { + "description": "Rendered container height.", + "type": "number" + }, + "collection": { + "description": "Items a forEach loop or collection parallel iterates." + }, + "count": { + "description": "Iteration count for a `for` loop or count parallel.", + "type": "number" + }, + "loopType": { + "description": "Loop container kind.", + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "parallelType": { + "description": "Parallel container kind.", + "type": "string", + "enum": ["collection", "count"] + }, + "batchSize": { + "description": "Maximum concurrent branches of a parallel.", + "type": "number" + }, + "type": { + "description": "Container subtype.", + "type": "string" + }, + "canonicalModes": { + "description": "Per-field editing mode, keyed by canonical parameter id.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["basic", "advanced"] + } + } + }, + "additionalProperties": false, + "description": "Container and layout metadata carried by a block." + }, + "locked": { + "description": "Whether the block is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], + "additionalProperties": false, + "title": "Workflow block", + "description": "One node of a workflow graph and its configuration." + }, + "WorkflowEdge": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Edge identifier, unique within the workflow." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Source block id." + }, + "target": { + "type": "string", + "minLength": 1, + "description": "Target block id." + }, + "sourceHandle": { + "description": "Source port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetHandle": { + "description": "Target port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "description": "Edge renderer type.", + "type": "string" + } + }, + "required": ["id", "source", "target"], + "additionalProperties": false, + "title": "Workflow edge", + "description": "A directed connection between two blocks." + }, + "WorkflowLoop": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Loop container identifier; equal to the loop block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the loop." + }, + "iterations": { + "type": "number", + "description": "Resolved iteration count." + }, + "loopType": { + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"], + "description": "Loop kind." + }, + "forEachItems": { + "description": "Items a forEach loop iterates, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item the loop iterates." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item the loop iterates." + } + }, + { + "type": "string" + } + ] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "enabled": { + "description": "Whether the loop runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the loop is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes", "iterations", "loopType"], + "additionalProperties": false, + "title": "Workflow loop", + "description": "A loop container derived from the workflow blocks." + }, + "WorkflowParallel": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Parallel container identifier; equal to the parallel block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the parallel." + }, + "distribution": { + "description": "Items distributed across branches, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item distributed to a branch." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item distributed to a branch." + } + }, + { + "type": "string" + } + ] + }, + "count": { + "description": "Fixed branch count.", + "type": "number" + }, + "parallelType": { + "description": "Parallel kind.", + "type": "string", + "enum": ["count", "collection"] + }, + "batchSize": { + "description": "Maximum concurrent branches.", + "type": "number" + }, + "enabled": { + "description": "Whether the parallel runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the parallel is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes"], + "additionalProperties": false, + "title": "Workflow parallel", + "description": "A parallel container derived from the workflow blocks." + }, + "WorkflowVariable": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Variable identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Variable name, referenced from block inputs." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"], + "description": "Declared variable type." + }, + "value": { + "description": "Variable value; free-form and validated per `type` at use time." + } + }, + "required": ["id", "name", "type", "value"], + "additionalProperties": false, + "title": "Workflow variable", + "description": "A workflow-scoped variable." + }, + "WorkflowGraph": { + "type": "object", + "properties": { + "blocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowBlock" + }, + "description": "Blocks keyed by block id." + }, + "edges": { + "maxItems": 10000, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEdge" + }, + "description": "Directed connections between blocks." + }, + "loops": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowLoop" + }, + "description": "Loop containers keyed by container id; always present, `{}` when there are none." + }, + "parallels": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowParallel" + }, + "description": "Parallel containers keyed by container id; always present, `{}` when there are none." + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowVariable" + }, + "description": "Workflow variables keyed by variable id; always present, `{}` when there are none." + } + }, + "required": ["blocks", "edges", "loops", "parallels", "variables"], + "additionalProperties": false, + "title": "Workflow graph", + "description": "The editable draft graph of a workflow: blocks, edges, derived loop and parallel containers, and variables." + }, + "WorkflowStateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowGraph" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow state response", + "description": "The editable draft graph of a workflow.", + "examples": [ + { + "data": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {}, + "variables": {} + } + } + ] + }, + "ReplaceWorkflowStateResult": { + "title": "Replace workflow state result", + "description": "Outcome of replacing a workflow draft graph.", + "$ref": "#/components/schemas/WorkflowGraphWriteResult" + }, + "WorkflowGraphWriteResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose draft graph was written." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + } + }, + "required": ["id", "warnings", "needsRedeployment"], + "additionalProperties": false, + "title": "Workflow graph write result", + "description": "Outcome of a write against a workflow draft graph." + }, + "ReplaceWorkflowStateResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose draft graph was written." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + } + }, + "required": ["id", "warnings", "needsRedeployment"], + "additionalProperties": false, + "description": "Response data.", + "$ref": "#/components/schemas/ReplaceWorkflowStateResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Replace workflow state response", + "description": "Outcome of replacing a workflow draft graph.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "warnings": [], + "needsRedeployment": true + } + } + ] + }, + "WorkflowBlockInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Block identifier, unique within the workflow." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Block display name; must be unique within the workflow." + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Canvas x coordinate." + }, + "y": { + "type": "number", + "description": "Canvas y coordinate." + } + }, + "required": ["x", "y"], + "description": "Canvas coordinates of a block." + }, + "subBlocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Sub-block identifier." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Sub-block input type." + }, + "value": { + "description": "Configured value; shape depends on the sub-block type." + } + }, + "required": ["id", "type", "value"], + "description": "One configurable input on a block." + }, + "description": "Configured inputs keyed by sub-block id." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Declared shape of one output; depends on the block type." + }, + "description": "Declared output shape keyed by output name." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block runs." + }, + "horizontalHandles": { + "description": "Whether edge handles render horizontally.", + "type": "boolean" + }, + "height": { + "description": "Rendered block height.", + "type": "number" + }, + "advancedMode": { + "description": "Whether the block is edited in advanced mode.", + "type": "boolean" + }, + "errorEnabled": { + "description": "Whether the block exposes an error branch.", + "type": "boolean" + }, + "retry": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the block retries on failure." + }, + "maxTries": { + "type": "integer", + "minimum": 2, + "maximum": 5, + "description": "Total attempts, including the first." + }, + "waitBetweenTriesMs": { + "type": "integer", + "minimum": 0, + "maximum": 5000, + "description": "Delay between attempts, in milliseconds." + } + }, + "required": ["enabled", "maxTries", "waitBetweenTriesMs"], + "description": "Per-block retry configuration." + }, + "triggerMode": { + "description": "Whether the block acts as the workflow trigger.", + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "parentId": { + "description": "Identifier of the containing loop or parallel.", + "type": "string" + }, + "extent": { + "description": "Constrains the block to its parent bounds.", + "type": "string", + "const": "parent" + }, + "width": { + "description": "Rendered container width.", + "type": "number" + }, + "height": { + "description": "Rendered container height.", + "type": "number" + }, + "collection": { + "description": "Items a forEach loop or collection parallel iterates." + }, + "count": { + "description": "Iteration count for a `for` loop or count parallel.", + "type": "number" + }, + "loopType": { + "description": "Loop container kind.", + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "parallelType": { + "description": "Parallel container kind.", + "type": "string", + "enum": ["collection", "count"] + }, + "batchSize": { + "description": "Maximum concurrent branches of a parallel.", + "type": "number" + }, + "type": { + "description": "Container subtype.", + "type": "string" + }, + "canonicalModes": { + "description": "Per-field editing mode, keyed by canonical parameter id.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["basic", "advanced"] + } + } + }, + "description": "Container and layout metadata carried by a block." + }, + "locked": { + "description": "Whether the block is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], + "title": "Workflow block", + "description": "One node of a workflow graph and its configuration." + }, + "WorkflowEdgeInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Edge identifier, unique within the workflow." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Source block id." + }, + "target": { + "type": "string", + "minLength": 1, + "description": "Target block id." + }, + "sourceHandle": { + "description": "Source port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetHandle": { + "description": "Target port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "description": "Edge renderer type.", + "type": "string" + } + }, + "required": ["id", "source", "target"], + "title": "Workflow edge", + "description": "A directed connection between two blocks." + }, + "WorkflowLoopInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Loop container identifier; equal to the loop block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the loop." + }, + "iterations": { + "type": "number", + "description": "Resolved iteration count." + }, + "loopType": { + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"], + "description": "Loop kind." + }, + "forEachItems": { + "description": "Items a forEach loop iterates, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item the loop iterates." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item the loop iterates." + } + }, + { + "type": "string" + } + ] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "enabled": { + "description": "Whether the loop runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the loop is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes", "iterations", "loopType"], + "title": "Workflow loop", + "description": "A loop container derived from the workflow blocks." + }, + "WorkflowParallelInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Parallel container identifier; equal to the parallel block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the parallel." + }, + "distribution": { + "description": "Items distributed across branches, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item distributed to a branch." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item distributed to a branch." + } + }, + { + "type": "string" + } + ] + }, + "count": { + "description": "Fixed branch count.", + "type": "number" + }, + "parallelType": { + "description": "Parallel kind.", + "type": "string", + "enum": ["count", "collection"] + }, + "batchSize": { + "description": "Maximum concurrent branches.", + "type": "number" + }, + "enabled": { + "description": "Whether the parallel runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the parallel is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes"], + "title": "Workflow parallel", + "description": "A parallel container derived from the workflow blocks." + }, + "WorkflowVariableInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Variable identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Variable name, referenced from block inputs." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"], + "description": "Declared variable type." + }, + "value": { + "description": "Variable value; free-form and validated per `type` at use time." + } + }, + "required": ["id", "name", "type", "value"], + "title": "Workflow variable", + "description": "A workflow-scoped variable." + }, + "ReplaceWorkflowStateRequest": { + "type": "object", + "properties": { + "blocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowBlockInput" + }, + "description": "Blocks keyed by block id." + }, + "edges": { + "maxItems": 10000, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEdgeInput" + }, + "description": "Directed connections between blocks." + }, + "loops": { + "description": "Ignored on write: loop containers are recomputed from `blocks`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowLoopInput" + } + }, + "parallels": { + "description": "Ignored on write: parallel containers are recomputed from `blocks`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowParallelInput" + } + }, + "variables": { + "description": "Replacement variable set. Omit to leave the stored variables untouched.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowVariableInput" + } } - } + }, + "required": ["blocks", "edges"], + "additionalProperties": false, + "title": "Replace workflow state request", + "description": "A complete replacement draft graph for a workflow.", + "examples": [ + { + "blocks": {}, + "edges": [] + } + ] }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "WorkflowSkippedItem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "block_not_found", + "invalid_block_type", + "block_not_allowed", + "block_locked", + "tool_not_allowed", + "invalid_edge_target", + "invalid_edge_source", + "invalid_edge_scope", + "invalid_source_handle", + "invalid_target_handle", + "invalid_subblock_field", + "missing_required_params", + "invalid_subflow_parent", + "nested_subflow_not_allowed", + "duplicate_block_name", + "reserved_block_name", + "duplicate_trigger", + "duplicate_single_instance_block" + ], + "description": "Machine-readable reason the engine declined an operation." + }, + "operationType": { + "type": "string", + "description": "The `operation_type` that was declined." + }, + "blockId": { + "type": "string", + "description": "Block the declined operation targeted." + }, + "reason": { + "type": "string", + "description": "Human-readable explanation." + }, + "details": { + "description": "Additional context for the reason; keys depend on `type`.", + "type": "object", + "propertyNames": { + "type": "string" }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" - } + "additionalProperties": { + "description": "One piece of engine-supplied context for the reason." } } - } + }, + "required": ["type", "operationType", "blockId", "reason"], + "additionalProperties": false, + "title": "Workflow skipped item", + "description": "One operation the edit engine did not apply." }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" + "WorkflowInputValidationError": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block whose input was rejected." + }, + "blockType": { + "type": "string", + "description": "Type of the block whose input was rejected." + }, + "field": { + "type": "string", + "description": "Sub-block field that was rejected." + }, + "error": { + "type": "string", + "description": "Why the value was rejected." } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + "required": ["blockId", "blockType", "field", "error"], + "additionalProperties": false, + "title": "Workflow input validation error", + "description": "One block input that was dropped rather than persisted." + }, + "WorkflowLintReport": { + "type": "object", + "properties": { + "unresolvedReferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block holding the reference." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Type of the block holding the reference." + }, + "field": { + "type": "string", + "description": "Sub-block field holding the reference." + }, + "reason": { + "type": "string", + "description": "Why the reference does not resolve." + } + }, + "required": ["blockId", "blockType", "field", "reason"], + "additionalProperties": false }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" - } - } + "description": "Credential, resource, tool, and skill references that do not resolve." + }, + "notes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Advisory notes about the report itself." } - } - } - }, - "schemas": { - "V2Error": { + }, + "required": ["unresolvedReferences", "notes"], + "additionalProperties": false, + "title": "Workflow lint report", + "description": "Advisory findings about the saved graph. Findings never block the write; they tell a caller what will misbehave at run time." + }, + "ApplyWorkflowOperationsResult": { "type": "object", "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." - }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." - }, - "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." - } + "id": { + "type": "string", + "description": "Identifier of the workflow whose draft graph was written." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + }, + "applied": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Operations the engine applied." + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSkippedItem" + }, + "description": "Operations the engine declined. Empty when everything applied." + }, + "deferred": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSkippedItem" + }, + "description": "Forward-referencing edges the engine recorded rather than applied. These are NOT failures: the engine wires each one as soon as its target block exists, in this batch or a later one. Do not re-issue them." + }, + "inputValidationErrors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowInputValidationError" + }, + "description": "Block inputs that were dropped. The rest of the operation still applied." + }, + "lint": { + "$ref": "#/components/schemas/WorkflowLintReport" } }, - "required": ["error"], + "required": [ + "id", + "warnings", + "needsRedeployment", + "applied", + "skipped", + "deferred", + "inputValidationErrors", + "lint" + ], "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", + "title": "Apply workflow operations result", + "description": "Outcome of a batch of semantic edits against a workflow graph." + }, + "ApplyWorkflowOperationsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ApplyWorkflowOperationsResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Apply workflow operations response", + "description": "Outcome of a batch of semantic edits.", "examples": [ { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "applied": 1, + "skipped": [], + "deferred": [], + "inputValidationErrors": [], + "lint": { + "unresolvedReferences": [], + "notes": [] + }, + "warnings": [], + "needsRedeployment": true } } ] }, - "FolderPathInput": { - "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" - }, - "WorkflowListItem": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "WorkflowEditOperation": { + "oneOf": [ + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "add", + "description": "Create a new block." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Block display name." + } + }, + "required": ["type", "name"], + "additionalProperties": { + "description": "One block-specific input or connection descriptor." + }, + "description": "Block type and name, plus any block-specific inputs and connections." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false }, - "name": { - "type": "string", - "description": "Workflow name.", - "examples": ["Customer support triage"] + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "edit", + "description": "Change an existing block: its inputs, name, or connections." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One operation parameter; its shape depends on the target block type." + }, + "description": "Operation parameters; the accepted keys depend on the target block type." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false }, - "description": { - "anyOf": [ - { - "type": "string" + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "delete", + "description": "Remove a block and every edge touching it." }, - { - "type": "null" + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + } + }, + "required": ["operation_type", "block_id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "insert_into_subflow", + "description": "Create a block inside a loop or parallel container." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "subflowId": { + "type": "string", + "minLength": 1, + "description": "Loop or parallel container to insert the block into." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Block display name." + } + }, + "required": ["subflowId", "type", "name"], + "additionalProperties": { + "description": "One block-specific input or connection descriptor." + }, + "description": "Container, block type and name, plus any block-specific inputs and connections." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "extract_from_subflow", + "description": "Move a block out of its loop or parallel container." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "subflowId": { + "type": "string", + "minLength": 1, + "description": "Loop or parallel container the block moves into or out of." + } + }, + "required": ["subflowId"], + "additionalProperties": { + "description": "One block-specific input." + }, + "description": "Container identifier, plus any block-specific inputs." } - ], - "description": "Workflow description, or null when none is set." + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + } + ], + "title": "Workflow edit operation", + "description": "One semantic edit against a workflow graph." + }, + "ApplyWorkflowOperationsRequest": { + "type": "object", + "properties": { + "operations": { + "minItems": 1, + "maxItems": 200, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEditOperation" + }, + "description": "Edits to apply, in a single batch." }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Operations"] + "atomic": { + "default": false, + "description": "Fail the whole batch when any operation is declined. The default applies what it can and reports the rest in `skipped`; `true` writes nothing and answers `409` instead.", + "type": "boolean" }, - "workspaceId": { + "layout": { + "default": "targeted", + "description": "Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied.", "type": "string", - "description": "Workspace that owns the workflow." - }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow has an active deployment." + "enum": ["targeted", "none"] }, - "deployedAt": { - "anyOf": [ - { - "type": "string" + "setBlockEnabled": { + "description": "Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined.", + "maxItems": 200, + "type": "array", + "items": { + "type": "object", + "properties": { + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block should run." + } }, + "required": ["block_id", "enabled"], + "additionalProperties": false + } + } + }, + "required": ["operations"], + "additionalProperties": false, + "title": "Apply workflow operations request", + "description": "A batch of semantic edits against a workflow graph.", + "examples": [ + { + "operations": [ { - "type": "null" + "operation_type": "add", + "block_id": "agent-1", + "params": { + "type": "agent", + "name": "Triage" + } } - ], - "description": "ISO 8601 activation timestamp, or null when not deployed.", - "format": "date-time" + ] + } + ] + }, + "ApplyWorkflowVariablesResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose variables were updated." }, - "runCount": { + "variableCount": { "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{id}/runs`, in either direction." - }, - "lastRunAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was created.", - "format": "date-time" + "description": "Variables the workflow now holds." }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" + "changed": { + "type": "boolean", + "description": "Whether anything actually changed. A no-op batch answers `200` with `false`." } }, - "required": [ - "id", - "name", - "description", - "folderPath", - "workspaceId", - "isDeployed", - "deployedAt", - "runCount", - "lastRunAt", - "createdAt", - "updatedAt" - ], + "required": ["id", "variableCount", "changed"], "additionalProperties": false, - "title": "Workflow summary", - "description": "Summary of a workflow and its deployment and run state." + "title": "Apply workflow variables result", + "description": "Outcome of a workflow variable update." }, - "WorkflowListResponse": { + "ApplyWorkflowVariablesResponse": { "type": "object", "properties": { "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ApplyWorkflowVariablesResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Apply workflow variables response", + "description": "Outcome of a workflow variable update.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "variableCount": 3, + "changed": true + } + } + ] + }, + "ApplyWorkflowVariablesRequest": { + "type": "object", + "properties": { + "operations": { + "minItems": 1, + "maxItems": 100, "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowListItem" + "oneOf": [ + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "add", + "description": "Create a variable with this name." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Variable name." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"], + "description": "Declared variable type." + }, + "value": { + "description": "Variable value, coerced to `type`." + } + }, + "required": ["operation", "name", "type", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "edit", + "description": "Replace the value, and optionally the type, of an existing variable." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name of the variable to update." + }, + "type": { + "description": "Replacement type; the stored type is kept when omitted.", + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"] + }, + "value": { + "description": "Replacement value, coerced to the effective type." + } + }, + "required": ["operation", "name", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "delete", + "description": "Remove the variable with this name." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name of the variable to remove." + } + }, + "required": ["operation", "name"], + "additionalProperties": false + } + ], + "description": "One variable change." }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Variable changes to apply, in order." } }, - "required": ["data", "nextCursor"], + "required": ["operations"], "additionalProperties": false, - "title": "Workflow list response", - "description": "A cursor-paginated page of workflow summaries.", + "title": "Apply workflow variables request", + "description": "Additions, edits, and deletions against a workflow’s variables." + }, + "DuplicateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Duplicate workflow response", + "description": "The created copy.", "examples": [ { - "data": [ - { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" - } - ], - "nextCursor": null + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage (copy)", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } } ] }, - "CreateWorkflowResponse": { + "DuplicateWorkflowRequest": { + "type": "object", + "properties": { + "name": { + "description": "Name for the copy. Defaults to the source name, deduplicated within the folder.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "folderPath": { + "description": "Destination folder path. Defaults to the source workflow's folder.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "additionalProperties": false, + "title": "Duplicate workflow request", + "description": "Optional name and destination folder for the copy." + }, + "RestoreWorkflowResponse": { "type": "object", "properties": { "data": { @@ -2557,8 +4978,8 @@ }, "required": ["data"], "additionalProperties": false, - "title": "Create workflow response", - "description": "The created workflow summary.", + "title": "Restore workflow response", + "description": "The restored workflow.", "examples": [ { "data": { @@ -2577,41 +4998,85 @@ } ] }, - "CreateWorkflowRequest": { + "MoveWorkflowsResult": { + "type": "object", + "properties": { + "moved": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workflows that were relocated." + }, + "failed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workflows that were not relocated — absent from the workspace, archived, or locked. Best-effort by design: the rest of the batch still moved." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical destination folder path.", + "maxLength": 4096 + } + }, + "required": ["moved", "failed", "folderPath"], + "additionalProperties": false, + "title": "Move workflows result", + "description": "Which workflows moved and which did not." + }, + "MoveWorkflowsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/MoveWorkflowsResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Move workflows response", + "description": "Which workflows moved and which did not.", + "examples": [ + { + "data": { + "moved": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"], + "failed": [], + "folderPath": "/Operations" + } + } + ] + }, + "MoveWorkflowsRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the workflow." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Workflow name." + "description": "Workspace holding every workflow in the batch." }, - "description": { - "description": "Optional workflow description.", - "anyOf": [ - { - "type": "string", - "maxLength": 50000 - }, - { - "type": "null" - } - ] + "workflowIds": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Workflows to move. Duplicates are collapsed." }, "folderPath": { + "description": "Destination folder path; `/` moves the workflows to the workspace root.", "$ref": "#/components/schemas/FolderPathInput" } }, - "required": ["workspaceId", "name"], + "required": ["workspaceId", "workflowIds", "folderPath"], "additionalProperties": false, - "title": "Create workflow request", - "description": "Name, description, workspace, and optional folder for a new workflow." + "title": "Move workflows request", + "description": "Workflows to relocate and the folder to relocate them into." }, "WorkflowInputField": { "type": "object", @@ -2847,18 +5312,23 @@ "properties": { "id": { "type": "string", - "description": "Identifier of the deleted workflow." + "description": "Identifier of the archived workflow." }, "deleted": { "type": "boolean", "const": true, - "description": "Confirms that the workflow was deleted." + "description": "Confirms that the workflow is no longer live." + }, + "archived": { + "type": "boolean", + "const": true, + "description": "The workflow was archived, not erased. Its schedules, webhooks, MCP tools, and chats were archived with it, and `POST /workflows/{id}/restore` brings all of them back." } }, - "required": ["id", "deleted"], + "required": ["id", "deleted", "archived"], "additionalProperties": false, "title": "Delete workflow result", - "description": "Confirmation that a workflow was deleted." + "description": "Confirmation that a workflow was archived." }, "DeleteWorkflowResponse": { "type": "object", @@ -2871,12 +5341,13 @@ "required": ["data"], "additionalProperties": false, "title": "Delete workflow response", - "description": "Confirmation that the workflow was deleted.", + "description": "Confirmation that the workflow was archived.", "examples": [ { "data": { "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "deleted": true + "deleted": true, + "archived": true } } ] diff --git a/apps/sim/app/api/v2/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/v2/workflows/[id]/duplicate/route.ts new file mode 100644 index 00000000000..8b2d19fcc24 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/duplicate/route.ts @@ -0,0 +1,37 @@ +import { v2DuplicateWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { duplicateWorkflow } from '@/lib/workflows/application/duplicate-workflow' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +export const POST = defineV2JsonRoute({ + contract: v2DuplicateWorkflowContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.duplicate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, body }) => ({ + sourceWorkflowId: params.id, + name: body.name, + folderPath: body.folderPath, + }), + useCase: duplicateWorkflow, + present: (result) => ({ + data: { + id: result.id, + name: result.name, + description: result.description, + folderPath: result.folderPath, + workspaceId: result.workspaceId, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: result.createdAt.toISOString(), + updatedAt: result.updatedAt.toISOString(), + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/operations/route.ts b/apps/sim/app/api/v2/workflows/[id]/operations/route.ts new file mode 100644 index 00000000000..5956b90ce97 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/operations/route.ts @@ -0,0 +1,62 @@ +import { v2ApplyWorkflowOperationsContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { applyWorkflowOperations } from '@/lib/workflows/application/apply-workflow-operations' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Semantic edits against a workflow graph. + * + * Best-effort per operation, atomic per write: the engine applies what it can + * and reports the rest in `skipped`, and exactly one write of the fully-resolved + * graph happens at the end. `atomic: true` moves the decision in front of that + * write and answers `409` instead, so nothing is persisted. + */ +export const POST = defineV2JsonRoute({ + contract: v2ApplyWorkflowOperationsContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.applyOperations, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowGraphAuthorization, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + mapInput: ({ params, body }) => ({ + workflowId: params.id, + operations: body.operations, + atomic: body.atomic, + layout: body.layout, + blockEnabledChanges: body.setBlockEnabled?.map((change) => ({ + blockId: change.block_id, + enabled: change.enabled, + })), + }), + useCase: applyWorkflowOperations, + present: (result) => ({ + data: { + id: result.workflowId, + applied: result.applied, + skipped: result.skipped, + deferred: result.deferred, + inputValidationErrors: result.inputValidationErrors.map((error) => ({ + blockId: error.blockId, + blockType: error.blockType, + field: error.field, + error: error.error, + })), + lint: { + unresolvedReferences: result.lint.unresolvedReferences.map((reference) => ({ + blockId: reference.blockId, + blockType: reference.blockType ?? null, + field: reference.field, + reason: reference.reason, + })), + notes: result.lint.notes, + }, + warnings: result.warnings, + needsRedeployment: result.needsRedeployment, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/restore/route.ts b/apps/sim/app/api/v2/workflows/[id]/restore/route.ts new file mode 100644 index 00000000000..abf9fc6bd50 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/restore/route.ts @@ -0,0 +1,38 @@ +import { v2RestoreWorkflowContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { restoreWorkflow } from '@/lib/workflows/application/restore-workflow' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Un-archives a workflow, along with the schedules, webhooks, MCP tools, and + * chats that were archived with it. A workflow that is not archived is a `409`, + * not a silent success. + */ +export const POST = defineV2JsonRoute({ + contract: v2RestoreWorkflowContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.restore, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: restoreWorkflow, + present: ({ workflow, workspaceId, folderPath }) => ({ + data: { + id: workflow.id, + name: workflow.name, + description: workflow.description, + folderPath, + workspaceId, + isDeployed: workflow.isDeployed, + deployedAt: workflow.deployedAt?.toISOString() ?? null, + runCount: workflow.runCount, + lastRunAt: workflow.lastRunAt?.toISOString() ?? null, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.ts b/apps/sim/app/api/v2/workflows/[id]/route.ts index 9e1d6be489e..89e4f203efc 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.ts @@ -84,5 +84,7 @@ export const DELETE = defineV2JsonRoute({ errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, mapInput: ({ params }) => ({ workflowId: params.id }), useCase: deleteWorkflow, - present: ({ workflowId }) => ({ data: { id: workflowId, deleted: true as const } }), + present: ({ workflowId }) => ({ + data: { id: workflowId, deleted: true as const, archived: true as const }, + }), }) diff --git a/apps/sim/app/api/v2/workflows/[id]/state/route.ts b/apps/sim/app/api/v2/workflows/[id]/state/route.ts new file mode 100644 index 00000000000..23b825ef273 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/state/route.ts @@ -0,0 +1,58 @@ +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { + v2GetWorkflowStateContract, + v2ReplaceWorkflowStateContract, +} from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readWorkflowGraph } from '@/lib/workflows/application/read-workflow-graph' +import { replaceWorkflowState } from '@/lib/workflows/application/replace-workflow-state' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Head-safe for the same reason `GET /api/v2/workflows/{id}` is: the only write + * this read can trigger is migrate-on-read inside + * `loadWorkflowFromNormalizedTables`, which is conditional, idempotent, and + * convergent — a `HEAD` only brings forward a write the next ordinary read + * performs. + * + * It records no audit event, and that is what makes it pollable. `/export` is + * the audited, portable, sanitized read; this one is the unsanitized draft a + * caller reads before writing it back. + */ +export const GET = defineV2JsonRoute({ + contract: v2GetWorkflowStateContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ workflowId: params.id }), + useCase: readWorkflowGraph, + present: ({ blocks, edges, loops, parallels, variables }) => ({ + data: { blocks, edges, loops, parallels, variables }, + }), +}) + +export const PUT = defineV2JsonRoute({ + contract: v2ReplaceWorkflowStateContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.replaceState, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + mapInput: ({ params, body }) => ({ + workflowId: params.id, + // double-cast-allowed: the wire schema leaves sub-block `type` an open string, which the domain type narrows to the block registry's union; the persistence layer re-validates every sub-block. + blocks: body.blocks as unknown as Record, + edges: body.edges as WorkflowState['edges'], + variables: body.variables, + }), + useCase: replaceWorkflowState, + present: ({ workflowId, warnings, needsRedeployment }) => ({ + data: { id: workflowId, warnings, needsRedeployment }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/variables/route.ts b/apps/sim/app/api/v2/workflows/[id]/variables/route.ts new file mode 100644 index 00000000000..4104535719a --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/variables/route.ts @@ -0,0 +1,33 @@ +import { v2ApplyWorkflowVariablesContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { applyWorkflowVariableOperations } from '@/lib/workflows/application/update-workflow-content' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Merge-patch shaped: only the named variables change, and a `delete` operation + * is how one is removed. A batch that changes nothing answers `200` with + * `changed: false` and writes neither a row nor an audit event. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2ApplyWorkflowVariablesContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.applyVariableOperations, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params, body }) => ({ + workflowId: params.id, + operations: body.operations.map((operation) => ({ + name: operation.name, + operation: operation.operation, + ...(operation.operation === 'delete' ? {} : { value: operation.value, type: operation.type }), + })), + }), + useCase: applyWorkflowVariableOperations, + present: (result, { params }) => ({ + data: { id: params.id, variableCount: result.updated, changed: result.changed }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/move/route.ts b/apps/sim/app/api/v2/workflows/move/route.ts new file mode 100644 index 00000000000..744db239f05 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/move/route.ts @@ -0,0 +1,40 @@ +import { v2MoveWorkflowsContract } from '@/lib/api/contracts/v2/workflows' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk' +import { workflowOperations } from '@/lib/workflows/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * Relocates up to 100 workflows into one folder. + * + * Explicitly best-effort: each workflow moves in its own transaction, and one + * that is absent from the workspace, archived, or locked lands in `failed` while + * the rest still move. An infrastructure fault is propagated rather than + * reported as a per-item failure. + * + * Sits beside `/workflows/folders` at the collection level so it cannot shadow + * `/workflows/{id}`. + */ +export const POST = defineV2JsonRoute({ + contract: v2MoveWorkflowsContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.moveBulk, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + workflowIds: body.workflowIds, + folderPath: body.folderPath, + }), + useCase: moveWorkflowsBulk, + present: (result, { body }) => ({ + data: { moved: result.moved, failed: result.failed, folderPath: body.folderPath }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index d291c231a6c..9835dcdb0a6 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -19,12 +19,14 @@ export const revalidate = 0 function workflowCursorFilters(query: { workspaceId: string folderPath?: string + scope: 'active' | 'archived' deployedOnly: boolean search?: string }) { return cursorScopeKey(cursorRoute(v2ListWorkflowsContract), { workspaceId: query.workspaceId, folderPath: query.folderPath, + scope: query.scope, deployedOnly: query.deployedOnly, search: query.search, }) @@ -39,6 +41,7 @@ export const GET = defineV2JsonRoute({ mapInput: ({ query }) => ({ workspaceId: query.workspaceId, folderPath: query.folderPath, + scope: query.scope, deployedOnly: query.deployedOnly, search: query.search, sortBy: query.sortBy, @@ -85,8 +88,13 @@ export const POST = defineV2JsonRoute({ errorPolicy: v2OrchestrationErrorPolicy, mapInput: ({ body }) => body, useCase: createWorkflow, - present: ({ workflow, folderPath }) => ({ + present: ({ workflow, folderPath, normalizedState }) => ({ data: { + blocks: Object.values(normalizedState.blocks).map((block) => ({ + id: block.id, + type: block.type, + name: block.name, + })), id: workflow.id, name: workflow.name, description: workflow.description ?? null, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index d52ec521993..753090521d8 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -176,6 +176,7 @@ const CURSOR_BINDINGS: Record = { 'GET /api/v2/workflows': [ 'workspaceId', 'folderPath', + 'scope', 'deployedOnly', 'search', 'sortBy', diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index ef62bb6fcdf..333d009375d 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -20,12 +20,15 @@ import { } from '@/lib/api/contracts/v2/openapi/shared' import { EXECUTE_OPTION_CONSTRAINTS, + v2ApplyWorkflowOperationsContract, + v2ApplyWorkflowVariablesContract, v2CancelWorkflowRunContract, v2CreateWorkflowContract, v2CreateWorkflowFolderContract, v2DeleteWorkflowContract, v2DeleteWorkflowFolderContract, v2DeployWorkflowContract, + v2DuplicateWorkflowContract, v2ExecuteWorkflowContract, v2ExecuteWorkflowQueuedResponseSchema, v2ExecuteWorkflowSyncResponseSchema, @@ -33,13 +36,17 @@ import { v2GetWorkflowContract, v2GetWorkflowDeploymentContract, v2GetWorkflowRunContract, + v2GetWorkflowStateContract, v2GetWorkflowVersionContract, v2ImportWorkflowContract, v2ListWorkflowFoldersContract, v2ListWorkflowRunsContract, v2ListWorkflowsContract, v2ListWorkflowVersionsContract, + v2MoveWorkflowsContract, v2RelocateWorkflowFolderContract, + v2ReplaceWorkflowStateContract, + v2RestoreWorkflowContract, v2ResumeWorkflowContract, v2ResumeWorkflowQueuedResponseSchema, v2ResumeWorkflowSyncResponseSchema, @@ -91,6 +98,14 @@ const WORKFLOW_VERSION_EXAMPLE = { latestOperationStatus: 'active', } as const +const WORKFLOW_GRAPH_EXAMPLE = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: {}, +} as const + const RUN_RESULT_EXAMPLE = { data: { runId: RUN_ID, @@ -165,7 +180,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'listWorkflows', summary: 'List Workflows', - description: `List workflows in a workspace with folder and deployment filters, search, sorting, and opaque cursor pagination. ${FOLDER_TREE_TOO_LARGE}`, + description: `List workflows in a workspace with lifecycle scope, folder and deployment filters, search, sorting, and opaque cursor pagination. \`scope\` defaults to \`active\`; pass \`archived\` to list workflows a \`DELETE\` archived. ${FOLDER_TREE_TOO_LARGE}`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'PayloadTooLarge'], success: jsonSuccess('A page of workflows.'), }), @@ -185,7 +200,7 @@ const declaredRoutes = [ workflowOperation({ operationId: 'createWorkflowV2', summary: 'Create Workflow', - description: `Create a workflow in a workspace root or canonical workflow folder. ${FOLDER_TREE_TOO_LARGE}`, + description: `Create a workflow in a workspace root or canonical workflow folder. The response carries the blocks the platform seeded the workflow with, so the start block's id is available without a second request — attach edges to it directly. ${FOLDER_TREE_TOO_LARGE}`, errors: [...WORKSPACE_ERRORS, 'NotFound', 'Conflict', 'Locked', 'PayloadTooLarge'], success: jsonSuccess('The created workflow.'), }), @@ -196,11 +211,201 @@ const declaredRoutes = [ v2CreateWorkflowContract.response.schema, 'CreateWorkflowResponse', 'Create workflow response', - 'The created workflow summary.', + 'The created workflow and the blocks it was seeded with.', + [ + { + data: { + ...WORKFLOW_EXAMPLE, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + blocks: [{ id: 'start-1', type: 'starter', name: 'Start' }], + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2GetWorkflowStateContract, + workflowOperation({ + operationId: 'getWorkflowState', + summary: 'Get Workflow State', + description: + 'Get the editable draft graph of a workflow: blocks, edges, the loop and parallel containers derived from them, and variables. This is the pollable read — it records no audit event, and `HEAD` mirrors `GET`. The payload is **unsanitized**: it carries workspace-scoped `credentialId`, `knowledgeBaseId`, and `tableId` values verbatim, so it is not portable to another workspace. Use `GET /workflows/{id}/export` for a portable, sanitized copy — and note that export is not a read-modify-write source, because sanitizing it drops every credential binding. Unknown members are stripped, so what this returns is exactly the set of keys `PUT /workflows/{id}/state` accepts.', + errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], + success: jsonSuccess('The workflow draft graph.'), + }), + { + params: v2GetWorkflowStateContract.params, + query: v2GetWorkflowStateContract.query, + response: documentedSchema( + v2GetWorkflowStateContract.response.schema, + 'WorkflowStateResponse', + 'Workflow state response', + 'The editable draft graph of a workflow.', + [{ data: WORKFLOW_GRAPH_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2ReplaceWorkflowStateContract, + workflowOperation({ + operationId: 'replaceWorkflowState', + summary: 'Replace Workflow State', + description: + 'Replace a workflow\u2019s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state and no conflict detection.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{id}/deploy` publishes the draft.', + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('The draft graph was replaced.'), + }), + { + params: v2ReplaceWorkflowStateContract.params, + query: v2ReplaceWorkflowStateContract.query, + body: v2ReplaceWorkflowStateContract.body, + response: documentedSchema( + v2ReplaceWorkflowStateContract.response.schema, + 'ReplaceWorkflowStateResponse', + 'Replace workflow state response', + 'Outcome of replacing a workflow draft graph.', + [{ data: { id: WORKFLOW_ID, warnings: [], needsRedeployment: true } }] + ), + } + ), + defineOpenApiRoute( + v2ApplyWorkflowOperationsContract, + workflowOperation({ + operationId: 'applyWorkflowOperations', + summary: 'Apply Workflow Operations', + description: + 'Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item then aborts before the write and answers `409` with `error.details.code: "OPERATIONS_NOT_APPLIED"` and the same `skipped` array, having persisted nothing.\n\nAs with `PUT /workflows/{id}/state`, this changes only the draft; deploy to publish it.', + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('The batch was applied.'), + }), + { + params: v2ApplyWorkflowOperationsContract.params, + query: v2ApplyWorkflowOperationsContract.query, + body: v2ApplyWorkflowOperationsContract.body, + response: documentedSchema( + v2ApplyWorkflowOperationsContract.response.schema, + 'ApplyWorkflowOperationsResponse', + 'Apply workflow operations response', + 'Outcome of a batch of semantic edits.', + [ + { + data: { + id: WORKFLOW_ID, + applied: 1, + skipped: [], + deferred: [], + inputValidationErrors: [], + lint: { unresolvedReferences: [], notes: [] }, + warnings: [], + needsRedeployment: true, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2ApplyWorkflowVariablesContract, + workflowOperation({ + operationId: 'applyWorkflowVariables', + summary: 'Update Workflow Variables', + description: + 'Add, edit, and delete a workflow\u2019s variables. Operations are matched by variable `name` and applied in order; a batch that changes nothing answers `200` with `changed: false`. Values are coerced to the declared `type`, and a value that cannot be coerced is stored as supplied. Read the current set from `variables` on `GET /workflows/{id}`.', + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('The variable set after the batch.'), + }), + { + params: v2ApplyWorkflowVariablesContract.params, + query: v2ApplyWorkflowVariablesContract.query, + body: v2ApplyWorkflowVariablesContract.body, + response: documentedSchema( + v2ApplyWorkflowVariablesContract.response.schema, + 'ApplyWorkflowVariablesResponse', + 'Apply workflow variables response', + 'Outcome of a workflow variable update.', + [{ data: { id: WORKFLOW_ID, variableCount: 3, changed: true } }] + ), + } + ), + defineOpenApiRoute( + v2DuplicateWorkflowContract, + workflowOperation({ + operationId: 'duplicateWorkflow', + summary: 'Duplicate Workflow', + description: `Copy a workflow, including its blocks, edges, subflows, and variables, into the same workspace. Omitting \`name\` reuses the source name; a collision inside the destination folder is deduplicated rather than refused. ${FOLDER_TREE_TOO_LARGE}`, + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('The created copy.'), + }), + { + params: v2DuplicateWorkflowContract.params, + query: v2DuplicateWorkflowContract.query, + body: v2DuplicateWorkflowContract.body, + response: documentedSchema( + v2DuplicateWorkflowContract.response.schema, + 'DuplicateWorkflowResponse', + 'Duplicate workflow response', + 'The created copy.', + [ + { + data: { + ...WORKFLOW_EXAMPLE, + name: 'Customer support triage (copy)', + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + }, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2RestoreWorkflowContract, + workflowOperation({ + operationId: 'restoreWorkflow', + summary: 'Restore Workflow', + description: `Bring an archived workflow back, along with the schedules, webhooks, MCP tools, and chats that were archived with it. A workflow that is not archived answers \`409\`. A workflow whose folder was archived is restored to the workspace root. ${FOLDER_TREE_TOO_LARGE}`, + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('The restored workflow.'), + }), + { + params: v2RestoreWorkflowContract.params, + query: v2RestoreWorkflowContract.query, + response: documentedSchema( + v2RestoreWorkflowContract.response.schema, + 'RestoreWorkflowResponse', + 'Restore workflow response', + 'The restored workflow.', [{ data: WORKFLOW_EXAMPLE }] ), } ), + defineOpenApiRoute( + v2MoveWorkflowsContract, + workflowOperation({ + operationId: 'moveWorkflows', + summary: 'Move Workflows', + description: `Relocate up to 100 workflows into one folder. Explicitly best-effort: each workflow moves in its own transaction, and one that is absent from the workspace, archived, or locked lands in \`failed\` while the rest still move. Duplicate ids are collapsed. ${FOLDER_TREE_TOO_LARGE}`, + errors: RESOURCE_MUTATION_ERRORS, + success: jsonSuccess('Which workflows moved and which did not.'), + }), + { + query: v2MoveWorkflowsContract.query, + body: v2MoveWorkflowsContract.body, + response: documentedSchema( + v2MoveWorkflowsContract.response.schema, + 'MoveWorkflowsResponse', + 'Move workflows response', + 'Which workflows moved and which did not.', + [{ data: { moved: [WORKFLOW_ID], failed: [], folderPath: '/Operations' } }] + ), + } + ), defineOpenApiRoute( v2GetWorkflowContract, workflowOperation({ @@ -249,9 +454,10 @@ const declaredRoutes = [ workflowOperation({ operationId: 'deleteWorkflowV2', summary: 'Delete Workflow', - description: 'Permanently delete a workflow and its associated mutable state.', + description: + 'Archive a workflow. Despite the verb, this is not an erasure: the workflow, and the schedules, webhooks, MCP tools, and chats attached to it, are stamped archived and stop running, and `POST /workflows/{id}/restore` brings all of them back. An archived workflow disappears from the default list and is reachable with `scope=archived`. The `deleted` field is retained for shipped clients; `archived` states what actually happened.', errors: [...RESOURCE_ERRORS, 'Locked'], - success: jsonSuccess('The workflow was deleted.'), + success: jsonSuccess('The workflow was archived.'), }), { query: v2DeleteWorkflowContract.query, @@ -260,8 +466,8 @@ const declaredRoutes = [ v2DeleteWorkflowContract.response.schema, 'DeleteWorkflowResponse', 'Delete workflow response', - 'Confirmation that the workflow was deleted.', - [{ data: { id: WORKFLOW_ID, deleted: true } }] + 'Confirmation that the workflow was archived.', + [{ data: { id: WORKFLOW_ID, deleted: true, archived: true } }] ), } ), diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 4432437b508..c84dc912e7e 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1,3 +1,9 @@ +import { + BLOCK_RETRY_MAX_TRIES, + BLOCK_RETRY_MAX_WAIT_MS, + BLOCK_RETRY_MIN_TRIES, + BLOCK_RETRY_MIN_WAIT_MS, +} from '@sim/workflow-types/workflow' import { z } from 'zod' import { activeDeploymentSummarySchema, @@ -48,6 +54,7 @@ import { } from '@/lib/api/contracts/workflows' import { MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS } from '@/lib/billing/execution-timeout-defaults' import { PERSISTED_WORKFLOW_EXECUTION_STATUSES } from '@/lib/logs/types' +import { WORKFLOW_SKIPPED_ITEM_TYPES } from '@/lib/workflows/editing/types' export const V2_WORKFLOW_RUN_ID_HEADER = 'X-Run-Id' @@ -129,9 +136,24 @@ export type V2WorkflowSortBy = (typeof v2WorkflowSortFields)[number] * sort convention. The keyset behind the cursor follows `sortBy`, so the cursor * carries the sort it was minted under and is rejected once that changes. */ +/** + * Listing scopes. Two-valued on purpose, diverging from the three-valued + * internal `workflowScopeSchema`: `all` drops the `archived_at` predicate + * entirely, so it can use neither of the workflow table's two partial indexes + * and degrades to a full workspace scan. Mirrors `v2FileScopeSchema`. + */ +export const v2WorkflowScopeSchema = z.enum(['active', 'archived']) + +export type V2WorkflowScope = z.output + export const v2ListWorkflowsQuerySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace whose workflows should be listed.'), + scope: v2WorkflowScopeSchema + .default('active') + .describe( + 'Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.' + ), folderPath: v2FolderPathInputSchema .optional() .describe(`Restrict results to workflows in this folder path. ${V2_FOLDER_FILTER_MISS}`), @@ -472,16 +494,60 @@ export type V2UpdateWorkflowBody = z.input export const v2DeleteWorkflowDataSchema = z .object({ - id: z.string().describe('Identifier of the deleted workflow.'), - deleted: z.literal(true).describe('Confirms that the workflow was deleted.'), + id: z.string().describe('Identifier of the archived workflow.'), + /** + * Retained for shipped clients. `DELETE` has always archived rather than + * erased; renaming it would break them, so `archived` states the semantics + * alongside it. + */ + deleted: z.literal(true).describe('Confirms that the workflow is no longer live.'), + archived: z + .literal(true) + .describe( + 'The workflow was archived, not erased. Its schedules, webhooks, MCP tools, and chats were archived with it, and `POST /workflows/{id}/restore` brings all of them back.' + ), }) .meta({ id: 'DeleteWorkflowResult', title: 'Delete workflow result', - description: 'Confirmation that a workflow was deleted.', + description: 'Confirmation that a workflow was archived.', }) export type V2DeleteWorkflowData = z.output +const v2SeededBlockSchema = z + .object({ + id: z.string().describe('Block identifier.'), + type: z.string().describe('Registered block type.'), + name: z.string().describe('Block display name.'), + }) + .strict() + .meta({ + id: 'SeededWorkflowBlock', + title: 'Seeded workflow block', + description: 'A block the platform placed in a newly created workflow.', + }) + +/** + * Create result. Carries the seeded blocks — deliberately a summary rather than + * the whole graph, which would reintroduce the unbounded response + * `GET /workflows/{id}/state` exists to keep off the common path. + */ +export const v2CreateWorkflowDataSchema = v2WorkflowListItemSchema + .extend({ + blocks: z + .array(v2SeededBlockSchema) + .describe( + 'Blocks seeded into the new workflow. Contains the start block; attach edges to its `id`.' + ), + }) + .meta({ + id: 'CreateWorkflowResult', + title: 'Create workflow result', + description: 'The created workflow and the blocks it was seeded with.', + }) + +export type V2CreateWorkflowData = z.output + export const v2CreateWorkflowContract = defineRouteContract({ method: 'POST', path: '/api/v2/workflows', @@ -489,7 +555,7 @@ export const v2CreateWorkflowContract = defineRouteContract({ body: v2CreateWorkflowBodySchema, response: { mode: 'json', - schema: v2DataResponse(v2WorkflowListItemSchema), + schema: v2DataResponse(v2CreateWorkflowDataSchema), status: 201, }, }) @@ -1531,3 +1597,857 @@ export const v2ImportWorkflowContract = defineRouteContract({ status: 201, }, }) + +/** + * Ceilings on a graph a caller may push. Neither the internal `workflowStateSchema` + * nor the normalized tables bound these, so without them a single request can + * ask the persistence layer to write an unbounded number of rows. Both sit an + * order of magnitude above the largest workflow observed in production. + */ +export const MAX_WORKFLOW_GRAPH_BLOCKS = 2000 +export const MAX_WORKFLOW_GRAPH_EDGES = 10_000 +/** Ceiling on one `POST /operations` batch. */ +export const MAX_WORKFLOW_EDIT_OPERATIONS = 200 +/** Ceiling on one `PATCH /variables` batch; mirrors the application use case's own cap. */ +export const MAX_WORKFLOW_VARIABLE_OPERATIONS = 100 +/** Ceiling on one bulk move; mirrors the application use case's own cap. */ +export const MAX_WORKFLOW_BULK_MOVES = 100 + +const v2WorkflowBlockPositionSchema = z + .object({ + x: z.number().describe('Canvas x coordinate.'), + y: z.number().describe('Canvas y coordinate.'), + }) + .describe('Canvas coordinates of a block.') + +const v2WorkflowBlockDataSchema = z + .object({ + parentId: z.string().optional().describe('Identifier of the containing loop or parallel.'), + extent: z.literal('parent').optional().describe('Constrains the block to its parent bounds.'), + width: z.number().optional().describe('Rendered container width.'), + height: z.number().optional().describe('Rendered container height.'), + collection: z + .unknown() + .optional() + .describe('Items a forEach loop or collection parallel iterates.'), + count: z.number().optional().describe('Iteration count for a `for` loop or count parallel.'), + loopType: z + .enum(['for', 'forEach', 'while', 'doWhile']) + .optional() + .describe('Loop container kind.'), + whileCondition: z.string().optional().describe('Condition expression for a `while` loop.'), + doWhileCondition: z.string().optional().describe('Condition expression for a `doWhile` loop.'), + parallelType: z.enum(['collection', 'count']).optional().describe('Parallel container kind.'), + batchSize: z.number().optional().describe('Maximum concurrent branches of a parallel.'), + type: z.string().optional().describe('Container subtype.'), + canonicalModes: z + .record(z.string(), z.enum(['basic', 'advanced'])) + .optional() + .describe('Per-field editing mode, keyed by canonical parameter id.'), + }) + .describe('Container and layout metadata carried by a block.') + +const v2WorkflowSubBlockSchema = z + .object({ + id: z.string().min(1, 'subBlock id cannot be empty').describe('Sub-block identifier.'), + type: z.string().min(1, 'subBlock type cannot be empty').describe('Sub-block input type.'), + value: z.unknown().describe('Configured value; shape depends on the sub-block type.'), + }) + .describe('One configurable input on a block.') + +const v2WorkflowBlockRetrySchema = z + .object({ + enabled: z.boolean().describe('Whether the block retries on failure.'), + maxTries: z + .number() + .int() + .min(BLOCK_RETRY_MIN_TRIES, `maxTries must be at least ${BLOCK_RETRY_MIN_TRIES}`) + .max(BLOCK_RETRY_MAX_TRIES, `maxTries cannot exceed ${BLOCK_RETRY_MAX_TRIES}`) + .describe('Total attempts, including the first.'), + waitBetweenTriesMs: z + .number() + .int() + .min(BLOCK_RETRY_MIN_WAIT_MS, 'waitBetweenTriesMs cannot be negative') + .max(BLOCK_RETRY_MAX_WAIT_MS, `waitBetweenTriesMs cannot exceed ${BLOCK_RETRY_MAX_WAIT_MS}ms`) + .describe('Delay between attempts, in milliseconds.'), + }) + .describe('Per-block retry configuration.') + +function workflowBlockSchema(id: string) { + return z + .object({ + id: z + .string() + .min(1, 'block id cannot be empty') + .describe('Block identifier, unique within the workflow.'), + type: z.string().min(1, 'block type cannot be empty').describe('Registered block type.'), + name: z + .string() + .min(1, 'block name cannot be empty') + .max(255, 'block name is too long') + .describe('Block display name; must be unique within the workflow.'), + position: v2WorkflowBlockPositionSchema, + subBlocks: z + .record(z.string(), v2WorkflowSubBlockSchema) + .describe('Configured inputs keyed by sub-block id.'), + outputs: z + .record( + z.string(), + z.unknown().describe('Declared shape of one output; depends on the block type.') + ) + .describe('Declared output shape keyed by output name.'), + enabled: z.boolean().describe('Whether the block runs.'), + horizontalHandles: z + .boolean() + .optional() + .describe('Whether edge handles render horizontally.'), + height: z.number().optional().describe('Rendered block height.'), + advancedMode: z + .boolean() + .optional() + .describe('Whether the block is edited in advanced mode.'), + errorEnabled: z.boolean().optional().describe('Whether the block exposes an error branch.'), + retry: v2WorkflowBlockRetrySchema.optional(), + triggerMode: z + .boolean() + .optional() + .describe('Whether the block acts as the workflow trigger.'), + data: v2WorkflowBlockDataSchema.optional(), + locked: z.boolean().optional().describe('Whether the block is locked against edits.'), + }) + .meta({ + id, + title: 'Workflow block', + description: 'One node of a workflow graph and its configuration.', + }) +} + +function workflowEdgeSchema(id: string) { + return z + .object({ + id: z + .string() + .min(1, 'edge id cannot be empty') + .describe('Edge identifier, unique within the workflow.'), + source: z.string().min(1, 'edge source cannot be empty').describe('Source block id.'), + target: z.string().min(1, 'edge target cannot be empty').describe('Target block id.'), + sourceHandle: z.string().nullish().describe('Source port, or null for the block default.'), + targetHandle: z.string().nullish().describe('Target port, or null for the block default.'), + type: z.string().optional().describe('Edge renderer type.'), + }) + .meta({ id, title: 'Workflow edge', description: 'A directed connection between two blocks.' }) +} + +function workflowLoopSchema(id: string) { + return z + .object({ + id: z.string().describe('Loop container identifier; equal to the loop block id.'), + nodes: z.array(z.string()).describe('Block ids inside the loop.'), + iterations: z.number().describe('Resolved iteration count.'), + loopType: z.enum(['for', 'forEach', 'while', 'doWhile']).describe('Loop kind.'), + forEachItems: z + .union([ + z.array(z.unknown().describe('One item the loop iterates.')), + z.record(z.string(), z.unknown().describe('One item the loop iterates.')), + z.string(), + ]) + .optional() + .describe('Items a forEach loop iterates, or the expression producing them.'), + whileCondition: z.string().optional().describe('Condition expression for a `while` loop.'), + doWhileCondition: z + .string() + .optional() + .describe('Condition expression for a `doWhile` loop.'), + enabled: z.boolean().optional().describe('Whether the loop runs.'), + locked: z.boolean().optional().describe('Whether the loop is locked against edits.'), + }) + .meta({ + id, + title: 'Workflow loop', + description: 'A loop container derived from the workflow blocks.', + }) +} + +function workflowParallelSchema(id: string) { + return z + .object({ + id: z.string().describe('Parallel container identifier; equal to the parallel block id.'), + nodes: z.array(z.string()).describe('Block ids inside the parallel.'), + distribution: z + .union([ + z.array(z.unknown().describe('One item distributed to a branch.')), + z.record(z.string(), z.unknown().describe('One item distributed to a branch.')), + z.string(), + ]) + .optional() + .describe('Items distributed across branches, or the expression producing them.'), + count: z.number().optional().describe('Fixed branch count.'), + parallelType: z.enum(['count', 'collection']).optional().describe('Parallel kind.'), + batchSize: z.number().optional().describe('Maximum concurrent branches.'), + enabled: z.boolean().optional().describe('Whether the parallel runs.'), + locked: z.boolean().optional().describe('Whether the parallel is locked against edits.'), + }) + .meta({ + id, + title: 'Workflow parallel', + description: 'A parallel container derived from the workflow blocks.', + }) +} + +/** + * A workflow variable on the graph surface. + * + * Deliberately carries no `workflowId`: the internal read stamps one for the + * client's cross-workflow variables store, and on this surface the path already + * names the workflow. + */ +function workflowVariableSchema(id: string) { + return z + .object({ + id: z.string().min(1, 'variable id cannot be empty').describe('Variable identifier.'), + name: z + .string() + .min(1, 'variable name cannot be empty') + .max(255, 'variable name is too long') + .describe('Variable name, referenced from block inputs.'), + type: z + .enum(['string', 'number', 'boolean', 'object', 'array', 'plain']) + .describe('Declared variable type.'), + value: z + .unknown() + .describe('Variable value; free-form and validated per `type` at use time.'), + }) + .meta({ id, title: 'Workflow variable', description: 'A workflow-scoped variable.' }) +} + +export const v2WorkflowBlockSchema = workflowBlockSchema('WorkflowBlock') +export const v2WorkflowEdgeSchema = workflowEdgeSchema('WorkflowEdge') +const v2WorkflowLoopSchema = workflowLoopSchema('WorkflowLoop') +const v2WorkflowParallelSchema = workflowParallelSchema('WorkflowParallel') +export const v2WorkflowVariableSchema = workflowVariableSchema('WorkflowVariable') + +/** + * The editable draft graph. + * + * A v2-local re-declaration rather than a reuse of the internal + * `workflowStateSchema`: that one carries write-only legacy keys (`lastSaved`, + * `isDeployed`, `deployedAt`, `metadata`) and bounds nothing. + * + * The graph elements above are deliberately **not** `.strict()`, unlike the + * request bodies that carry them. They are both a response schema and a stored + * shape, and a v2 response schema is `.parse`d on the way out — so a strict + * element would turn any block, edge, or variable carrying a key this surface + * has not published yet into a `500` on a plain read. Stripping instead makes + * the read the canonical projection, which is also what makes a read-modify-write + * round trip closed: what a caller reads back is exactly the set of keys it may + * send. + */ +export const v2WorkflowGraphSchema = z + .object({ + blocks: z + .record(z.string(), v2WorkflowBlockSchema) + .describe('Blocks keyed by block id.') + .refine( + (blocks) => Object.keys(blocks).length <= MAX_WORKFLOW_GRAPH_BLOCKS, + `blocks cannot exceed ${MAX_WORKFLOW_GRAPH_BLOCKS} entries` + ), + edges: z + .array(v2WorkflowEdgeSchema) + .max(MAX_WORKFLOW_GRAPH_EDGES, `edges cannot exceed ${MAX_WORKFLOW_GRAPH_EDGES} entries`) + .describe('Directed connections between blocks.'), + loops: z + .record(z.string(), v2WorkflowLoopSchema) + .describe('Loop containers keyed by container id; always present, `{}` when there are none.'), + parallels: z + .record(z.string(), v2WorkflowParallelSchema) + .describe( + 'Parallel containers keyed by container id; always present, `{}` when there are none.' + ), + variables: z + .record(z.string(), v2WorkflowVariableSchema) + .describe( + 'Workflow variables keyed by variable id; always present, `{}` when there are none.' + ), + }) + .strict() + .meta({ + id: 'WorkflowGraph', + title: 'Workflow graph', + description: + 'The editable draft graph of a workflow: blocks, edges, derived loop and parallel containers, and variables.', + }) + +export type V2WorkflowGraph = z.output + +/** + * Write-side graph elements. + * + * Structurally identical to the read schemas and deliberately so — what a caller + * reads back is exactly what it may send. They are built separately only to + * carry distinct OpenAPI component ids: a request body is generated in `input` + * mode and a response in `output` mode, and the two spellings of the same object + * genuinely differ, because an output object cannot carry unknown members having + * just had them stripped. + */ +const v2WorkflowBlockInputSchema = workflowBlockSchema('WorkflowBlockInput') +const v2WorkflowEdgeInputSchema = workflowEdgeSchema('WorkflowEdgeInput') +const v2WorkflowLoopInputSchema = workflowLoopSchema('WorkflowLoopInput') +const v2WorkflowParallelInputSchema = workflowParallelSchema('WorkflowParallelInput') +const v2WorkflowVariableInputSchema = workflowVariableSchema('WorkflowVariableInput') + +/** + * Replace body. `loops` and `parallels` are accepted but ignored — both are + * derived from the blocks on write, so declaring them optional keeps a + * read-modify-write round trip working without promising they are honoured. + */ +export const v2ReplaceWorkflowStateBodySchema = z + .object({ + blocks: z + .record(z.string(), v2WorkflowBlockInputSchema) + .describe('Blocks keyed by block id.') + .refine( + (blocks) => Object.keys(blocks).length <= MAX_WORKFLOW_GRAPH_BLOCKS, + `blocks cannot exceed ${MAX_WORKFLOW_GRAPH_BLOCKS} entries` + ), + edges: z + .array(v2WorkflowEdgeInputSchema) + .max(MAX_WORKFLOW_GRAPH_EDGES, `edges cannot exceed ${MAX_WORKFLOW_GRAPH_EDGES} entries`) + .describe('Directed connections between blocks.'), + loops: z + .record(z.string(), v2WorkflowLoopInputSchema) + .optional() + .describe('Ignored on write: loop containers are recomputed from `blocks`.'), + parallels: z + .record(z.string(), v2WorkflowParallelInputSchema) + .optional() + .describe('Ignored on write: parallel containers are recomputed from `blocks`.'), + variables: z + .record(z.string(), v2WorkflowVariableInputSchema) + .optional() + .describe('Replacement variable set. Omit to leave the stored variables untouched.'), + }) + .strict() + .meta({ + id: 'ReplaceWorkflowStateRequest', + title: 'Replace workflow state request', + description: 'A complete replacement draft graph for a workflow.', + examples: [{ blocks: {}, edges: [] }], + }) + +export type V2ReplaceWorkflowStateBody = z.input + +const v2WorkflowGraphWriteResultSchema = z + .object({ + id: z.string().describe('Identifier of the workflow whose draft graph was written.'), + warnings: z + .array(z.string()) + .describe( + 'Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report.' + ), + needsRedeployment: z + .boolean() + .describe( + 'Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it.' + ), + }) + .meta({ + id: 'WorkflowGraphWriteResult', + title: 'Workflow graph write result', + description: 'Outcome of a write against a workflow draft graph.', + }) + +export const v2ReplaceWorkflowStateDataSchema = v2WorkflowGraphWriteResultSchema.meta({ + id: 'ReplaceWorkflowStateResult', + title: 'Replace workflow state result', + description: 'Outcome of replacing a workflow draft graph.', +}) +export type V2ReplaceWorkflowStateData = z.output + +export const v2GetWorkflowStateContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/workflows/[id]/state', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowGraphSchema), + }, +}) + +export const v2ReplaceWorkflowStateContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/workflows/[id]/state', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + body: v2ReplaceWorkflowStateBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ReplaceWorkflowStateDataSchema), + }, +}) + +/** + * Every reason the edit engine can decline one operation. Derived from the + * engine's own union, so a new skip reason fails to compile until it is + * published here. + */ +export const v2WorkflowSkippedItemTypeSchema = z + .enum(WORKFLOW_SKIPPED_ITEM_TYPES) + .describe('Machine-readable reason the engine declined an operation.') + +const v2WorkflowSkippedItemSchema = z + .object({ + type: v2WorkflowSkippedItemTypeSchema, + operationType: z.string().describe('The `operation_type` that was declined.'), + blockId: z.string().describe('Block the declined operation targeted.'), + reason: z.string().describe('Human-readable explanation.'), + /** Engine-supplied context; its keys vary by `type`. */ + details: z + .record( + z.string(), + z.unknown().describe('One piece of engine-supplied context for the reason.') + ) + .optional() + .describe('Additional context for the reason; keys depend on `type`.'), + }) + .meta({ + id: 'WorkflowSkippedItem', + title: 'Workflow skipped item', + description: 'One operation the edit engine did not apply.', + }) + +export type V2WorkflowSkippedItem = z.output + +const v2WorkflowOperationParamsSchema = z + .record( + z.string(), + z.unknown().describe('One operation parameter; its shape depends on the target block type.') + ) + .describe('Operation parameters; the accepted keys depend on the target block type.') + +const v2AddWorkflowBlockParamsSchema = z + .object({ + type: z + .string() + .min(1, 'params.type is required to add a block') + .describe('Registered block type.'), + name: z + .string() + .min(1, 'params.name is required to add a block') + .describe('Block display name.'), + }) + .catchall(z.unknown().describe('One block-specific input or connection descriptor.')) + .describe('Block type and name, plus any block-specific inputs and connections.') + +const v2SubflowMembershipParamsSchema = z + .object({ + subflowId: z + .string() + .min(1, 'params.subflowId is required') + .describe('Loop or parallel container the block moves into or out of.'), + }) + .catchall(z.unknown().describe('One block-specific input.')) + .describe('Container identifier, plus any block-specific inputs.') + +const v2InsertIntoSubflowParamsSchema = z + .object({ + subflowId: z + .string() + .min(1, 'params.subflowId is required') + .describe('Loop or parallel container to insert the block into.'), + type: z + .string() + .min(1, 'params.type is required to insert a block') + .describe('Registered block type.'), + name: z + .string() + .min(1, 'params.name is required to insert a block') + .describe('Block display name.'), + }) + .catchall(z.unknown().describe('One block-specific input or connection descriptor.')) + .describe('Container, block type and name, plus any block-specific inputs and connections.') + +const v2WorkflowOperationBlockIdSchema = z + .string() + .min(1, 'block_id cannot be empty') + .describe('Block the operation targets. For `add`, the id the new block will be given.') + +/** + * One semantic edit. A discriminated union on `operation_type` so a client gets + * exhaustive narrowing and each variant declares the parameters it actually + * requires — `add` and `insert_into_subflow` cannot omit the block type and + * name, and `delete` accepts no parameters at all. + */ +export const v2WorkflowOperationSchema = z + .discriminatedUnion('operation_type', [ + z + .object({ + operation_type: z.literal('add').describe('Create a new block.'), + block_id: v2WorkflowOperationBlockIdSchema, + params: v2AddWorkflowBlockParamsSchema, + }) + .strict(), + z + .object({ + operation_type: z + .literal('edit') + .describe('Change an existing block: its inputs, name, or connections.'), + block_id: v2WorkflowOperationBlockIdSchema, + params: v2WorkflowOperationParamsSchema, + }) + .strict(), + z + .object({ + operation_type: z.literal('delete').describe('Remove a block and every edge touching it.'), + block_id: v2WorkflowOperationBlockIdSchema, + }) + .strict(), + z + .object({ + operation_type: z + .literal('insert_into_subflow') + .describe('Create a block inside a loop or parallel container.'), + block_id: v2WorkflowOperationBlockIdSchema, + params: v2InsertIntoSubflowParamsSchema, + }) + .strict(), + z + .object({ + operation_type: z + .literal('extract_from_subflow') + .describe('Move a block out of its loop or parallel container.'), + block_id: v2WorkflowOperationBlockIdSchema, + params: v2SubflowMembershipParamsSchema, + }) + .strict(), + ]) + .meta({ + id: 'WorkflowEditOperation', + title: 'Workflow edit operation', + description: 'One semantic edit against a workflow graph.', + }) + +export type V2WorkflowOperation = z.input + +export const v2ApplyWorkflowOperationsBodySchema = z + .object({ + operations: z + .array(v2WorkflowOperationSchema) + .min(1, 'operations cannot be empty') + .max( + MAX_WORKFLOW_EDIT_OPERATIONS, + `operations cannot exceed ${MAX_WORKFLOW_EDIT_OPERATIONS} entries` + ) + .describe('Edits to apply, in a single batch.'), + atomic: z + .boolean() + .optional() + .default(false) + .describe( + 'Fail the whole batch when any operation is declined. The default applies what it can and reports the rest in `skipped`; `true` writes nothing and answers `409` instead.' + ), + layout: z + .enum(['targeted', 'none']) + .optional() + .default('targeted') + .describe( + 'Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied.' + ), + setBlockEnabled: z + .array( + z + .object({ + block_id: v2WorkflowOperationBlockIdSchema, + enabled: z.boolean().describe('Whether the block should run.'), + }) + .strict() + ) + .max( + MAX_WORKFLOW_EDIT_OPERATIONS, + `setBlockEnabled cannot exceed ${MAX_WORKFLOW_EDIT_OPERATIONS} entries` + ) + .optional() + .describe( + 'Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined.' + ), + }) + .strict() + .meta({ + id: 'ApplyWorkflowOperationsRequest', + title: 'Apply workflow operations request', + description: 'A batch of semantic edits against a workflow graph.', + examples: [ + { + operations: [ + { operation_type: 'add', block_id: 'agent-1', params: { type: 'agent', name: 'Triage' } }, + ], + }, + ], + }) + +export type V2ApplyWorkflowOperationsBody = z.input + +const v2WorkflowInputValidationErrorSchema = z + .object({ + blockId: z.string().describe('Block whose input was rejected.'), + blockType: z.string().describe('Type of the block whose input was rejected.'), + field: z.string().describe('Sub-block field that was rejected.'), + error: z.string().describe('Why the value was rejected.'), + }) + .meta({ + id: 'WorkflowInputValidationError', + title: 'Workflow input validation error', + description: 'One block input that was dropped rather than persisted.', + }) + +const v2WorkflowLintSchema = z + .object({ + unresolvedReferences: z + .array( + z.object({ + blockId: z.string().describe('Block holding the reference.'), + blockType: z.string().nullable().describe('Type of the block holding the reference.'), + field: z.string().describe('Sub-block field holding the reference.'), + reason: z.string().describe('Why the reference does not resolve.'), + }) + ) + .describe('Credential, resource, tool, and skill references that do not resolve.'), + notes: z.array(z.string()).describe('Advisory notes about the report itself.'), + }) + .meta({ + id: 'WorkflowLintReport', + title: 'Workflow lint report', + description: + 'Advisory findings about the saved graph. Findings never block the write; they tell a caller what will misbehave at run time.', + }) + +export const v2ApplyWorkflowOperationsDataSchema = v2WorkflowGraphWriteResultSchema + .extend({ + applied: z.number().int().nonnegative().describe('Operations the engine applied.'), + skipped: z + .array(v2WorkflowSkippedItemSchema) + .describe('Operations the engine declined. Empty when everything applied.'), + deferred: z + .array(v2WorkflowSkippedItemSchema) + .describe( + 'Forward-referencing edges the engine recorded rather than applied. These are NOT failures: the engine wires each one as soon as its target block exists, in this batch or a later one. Do not re-issue them.' + ), + inputValidationErrors: z + .array(v2WorkflowInputValidationErrorSchema) + .describe('Block inputs that were dropped. The rest of the operation still applied.'), + lint: v2WorkflowLintSchema, + }) + .meta({ + id: 'ApplyWorkflowOperationsResult', + title: 'Apply workflow operations result', + description: 'Outcome of a batch of semantic edits against a workflow graph.', + }) + +export type V2ApplyWorkflowOperationsData = z.output + +export const v2ApplyWorkflowOperationsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/operations', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + body: v2ApplyWorkflowOperationsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ApplyWorkflowOperationsDataSchema), + }, +}) + +export const v2ApplyWorkflowVariablesBodySchema = z + .object({ + operations: z + .array( + z + .discriminatedUnion('operation', [ + z + .object({ + operation: z.literal('add').describe('Create a variable with this name.'), + name: z + .string() + .min(1, 'name cannot be empty') + .max(255, 'name is too long') + .describe('Variable name.'), + type: v2WorkflowVariableInputSchema.shape.type.describe('Declared variable type.'), + value: z.unknown().describe('Variable value, coerced to `type`.'), + }) + .strict(), + z + .object({ + operation: z + .literal('edit') + .describe('Replace the value, and optionally the type, of an existing variable.'), + name: z + .string() + .min(1, 'name cannot be empty') + .max(255, 'name is too long') + .describe('Name of the variable to update.'), + type: v2WorkflowVariableInputSchema.shape.type + .optional() + .describe('Replacement type; the stored type is kept when omitted.'), + value: z.unknown().describe('Replacement value, coerced to the effective type.'), + }) + .strict(), + z + .object({ + operation: z.literal('delete').describe('Remove the variable with this name.'), + name: z + .string() + .min(1, 'name cannot be empty') + .max(255, 'name is too long') + .describe('Name of the variable to remove.'), + }) + .strict(), + ]) + .describe('One variable change.') + ) + .min(1, 'operations cannot be empty') + .max( + MAX_WORKFLOW_VARIABLE_OPERATIONS, + `operations cannot exceed ${MAX_WORKFLOW_VARIABLE_OPERATIONS} entries` + ) + .describe('Variable changes to apply, in order.'), + }) + .strict() + .meta({ + id: 'ApplyWorkflowVariablesRequest', + title: 'Apply workflow variables request', + description: 'Additions, edits, and deletions against a workflow’s variables.', + }) + +export type V2ApplyWorkflowVariablesBody = z.input + +export const v2ApplyWorkflowVariablesDataSchema = z + .object({ + id: z.string().describe('Identifier of the workflow whose variables were updated.'), + variableCount: z.number().int().nonnegative().describe('Variables the workflow now holds.'), + changed: z + .boolean() + .describe('Whether anything actually changed. A no-op batch answers `200` with `false`.'), + }) + .meta({ + id: 'ApplyWorkflowVariablesResult', + title: 'Apply workflow variables result', + description: 'Outcome of a workflow variable update.', + }) + +export type V2ApplyWorkflowVariablesData = z.output + +export const v2ApplyWorkflowVariablesContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workflows/[id]/variables', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + body: v2ApplyWorkflowVariablesBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2ApplyWorkflowVariablesDataSchema), + }, +}) + +export const v2DuplicateWorkflowBodySchema = z + .object({ + name: z + .string() + .trim() + .min(1, 'name cannot be empty') + .max(255, 'name is too long') + .optional() + .describe('Name for the copy. Defaults to the source name, deduplicated within the folder.'), + folderPath: v2FolderPathInputSchema + .optional() + .describe("Destination folder path. Defaults to the source workflow's folder."), + }) + .strict() + .meta({ + id: 'DuplicateWorkflowRequest', + title: 'Duplicate workflow request', + description: 'Optional name and destination folder for the copy.', + examples: [{ name: 'Customer support triage (copy)', folderPath: '/Operations' }], + }) + +export type V2DuplicateWorkflowBody = z.input + +export const v2DuplicateWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/duplicate', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + body: v2DuplicateWorkflowBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + status: 201, + }, +}) + +export const v2RestoreWorkflowContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/[id]/restore', + query: noInputSchema, + params: v2WorkflowIdParamsSchema, + response: { + mode: 'json', + schema: v2DataResponse(v2WorkflowListItemSchema), + }, +}) + +export const v2MoveWorkflowsBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace holding every workflow in the batch.'), + workflowIds: z + .array(z.string().min(1, 'workflowIds entries cannot be empty')) + .min(1, 'workflowIds cannot be empty') + .max(MAX_WORKFLOW_BULK_MOVES, `workflowIds cannot exceed ${MAX_WORKFLOW_BULK_MOVES} entries`) + .describe('Workflows to move. Duplicates are collapsed.'), + folderPath: v2FolderPathInputSchema.describe( + 'Destination folder path; `/` moves the workflows to the workspace root.' + ), + }) + .strict() + .meta({ + id: 'MoveWorkflowsRequest', + title: 'Move workflows request', + description: 'Workflows to relocate and the folder to relocate them into.', + examples: [ + { + workspaceId: 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64', + workflowIds: ['3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36'], + folderPath: '/Operations', + }, + ], + }) + +export type V2MoveWorkflowsBody = z.input + +export const v2MoveWorkflowsDataSchema = z + .object({ + moved: z.array(z.string()).describe('Workflows that were relocated.'), + failed: z + .array(z.string()) + .describe( + 'Workflows that were not relocated — absent from the workspace, archived, or locked. Best-effort by design: the rest of the batch still moved.' + ), + folderPath: v2FolderPathSchema.describe('Canonical destination folder path.'), + }) + .meta({ + id: 'MoveWorkflowsResult', + title: 'Move workflows result', + description: 'Which workflows moved and which did not.', + }) + +export type V2MoveWorkflowsData = z.output + +export const v2MoveWorkflowsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/workflows/move', + query: noInputSchema, + body: v2MoveWorkflowsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2MoveWorkflowsDataSchema), + }, +}) diff --git a/apps/sim/lib/copilot/sim-sandbox-projection.ts b/apps/sim/lib/copilot/sim-sandbox-projection.ts index 03f2edef75c..822fb3933ce 100644 --- a/apps/sim/lib/copilot/sim-sandbox-projection.ts +++ b/apps/sim/lib/copilot/sim-sandbox-projection.ts @@ -10,13 +10,3 @@ export const RESTRICTED_SIM_SANDBOX_INPUTS = new Map([ }, ], ]) - -/** Whether an edit_workflow operation tries to set or clear Function sandboxId. */ -export function operationsReferenceSimSandbox( - operations: ReadonlyArray<{ params?: Record }> -): boolean { - return operations.some((operation) => { - const inputs = operation.params?.inputs - return Boolean(inputs && typeof inputs === 'object' && 'sandboxId' in inputs) - }) -} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index b7ecfeed71a..f8ad47a75a3 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -1,415 +1,104 @@ -import { db } from '@sim/db' -import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { - assertWorkflowMutable, - authorizeWorkflowByWorkspacePermission, -} from '@sim/platform-authz/workflow' -import { toError } from '@sim/utils/errors' -import { eq } from 'drizzle-orm' -import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' -import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' +import { executeCopilotWorkflowUseCase } from '@/lib/copilot/application/execute-workflow-use-case' import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1' -import { operationsReferenceSimSandbox } from '@/lib/copilot/sim-sandbox-projection' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { env } from '@/lib/core/config/env' -import { getSocketServerUrl } from '@/lib/core/utils/urls' -import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { - applyTargetedLayout, - getTargetedLayoutImpact, - transferBlockHeights, -} from '@/lib/workflows/autolayout' -import { - DEFAULT_HORIZONTAL_SPACING, - DEFAULT_VERTICAL_SPACING, -} from '@/lib/workflows/autolayout/constants' -import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' -import { - loadWorkflowFromNormalizedTables, - saveWorkflowToNormalizedTables, -} from '@/lib/workflows/persistence/utils' -import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' -import { withBlockVisibility } from '@/blocks/visibility/server-context' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' -import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' -import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation' -import { applyOperationsToWorkflowState } from './engine' -import { - collectWorkflowFieldIssues, - formatWorkflowLintMessage, - hasWorkflowLintIssues, - lintEditedWorkflowState, - type WorkflowLintReport, - type WorkflowLintUnresolvedReference, -} from './lint' -import { type EditWorkflowParams, isDeferredSkippedItem, type ValidationError } from './types' import { - collectUnresolvedAgentToolReferences, - collectUnresolvedReferences, - preValidateCredentialInputs, - UNRESOLVABLE_AT_LINT_NOTE, -} from './validation' - -async function getCurrentWorkflowStateFromDb( - workflowId: string -): Promise<{ workflowState: any; subBlockValues: Record> }> { - const logger = createLogger('EditWorkflowServerTool') - const [workflowRecord] = await db - .select() - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) - if (!workflowRecord) throw new Error(`Workflow ${workflowId} not found in database`) - const normalized = await loadWorkflowFromNormalizedTables(workflowId) - if (!normalized) throw new Error('Workflow has no normalized data') - - const { state: validatedState, warnings } = normalizeWorkflowState({ - blocks: normalized.blocks, - edges: normalized.edges, - loops: normalized.loops || {}, - parallels: normalized.parallels || {}, - }) - - if (warnings.length > 0) { - logger.warn('Normalized workflow state loaded from DB for copilot', { - workflowId, - warningCount: warnings.length, - warnings, - }) + type ApplyWorkflowOperationsResult, + applyWorkflowOperations, +} from '@/lib/workflows/application/apply-workflow-operations' +import { formatWorkflowLintMessage, hasWorkflowLintIssues } from '@/lib/workflows/editing/lint' +import type { EditWorkflowParams, SkippedItem } from '@/lib/workflows/editing/types' + +const logger = createLogger('EditWorkflowServerTool') + +function mapSkippedItem(item: SkippedItem) { + return { + type: item.type, + operationType: item.operationType, + blockId: item.blockId, + reason: item.reason, + ...(item.details && { details: item.details }), } +} - const subBlockValues: Record> = {} - Object.entries(validatedState.blocks).forEach(([blockId, block]) => { - subBlockValues[blockId] = {} - Object.entries((block as any).subBlocks || {}).forEach(([subId, sub]) => { - if ((sub as any).value !== undefined) subBlockValues[blockId][subId] = (sub as any).value - }) - }) - return { workflowState: validatedState, subBlockValues } +function parseCurrentUserWorkflow(currentUserWorkflow: string): Record { + try { + return JSON.parse(currentUserWorkflow) + } catch (error) { + logger.error('Failed to parse currentUserWorkflow', error) + throw new Error('Invalid currentUserWorkflow format') + } } +/** + * Copilot's surface over the shared `workflows.operations.apply` use case. + * + * Owns only what a surface owns: argument shaping, abort checkpoints, and the + * tool result the model reads. Authorization, the lock and plan gates, the edit + * engine, persistence, semantic audit, and the realtime notification all live in + * the application use case, which `POST /api/v2/workflows/{id}/operations` + * enters through as well. + * + * `currentUserWorkflow` — the unsaved canvas the user is looking at — is passed + * through as `baseGraph`, which the use case honours only for a delegated + * principal. No other surface can supply it. + */ export const editWorkflowServerTool: BaseServerTool = { name: EditWorkflow.id, async execute(params: EditWorkflowParams, context?: ServerToolContext): Promise { - const logger = createLogger('EditWorkflowServerTool') const { operations, workflowId, currentUserWorkflow } = params if (!Array.isArray(operations) || operations.length === 0) { throw new Error('operations are required and must be an array') } if (!workflowId) throw new Error('workflowId is required') - if (!context?.userId) { - throw new Error('Unauthorized workflow access') - } - - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: context.userId, - action: 'write', - }) - if (!authorization.allowed) { - throw new Error(authorization.message || 'Unauthorized workflow access') - } - - await assertWorkflowMutable(workflowId) - - const workspaceId = authorization.workflow?.workspaceId ?? undefined - const workflowName = authorization.workflow?.name ?? undefined - - if ( - operationsReferenceSimSandbox(operations) && - (!workspaceId || !(await hasWorkspaceSandboxAccess(workspaceId))) - ) { - throw new Error(MAX_PLAN_REQUIRED) - } logger.info('Executing edit_workflow', { operationCount: operations.length, workflowId, hasCurrentUserWorkflow: !!currentUserWorkflow, - chatId: context.chatId, + chatId: context?.chatId, }) assertServerToolNotAborted(context) - let workflowState: any - if (currentUserWorkflow) { - try { - workflowState = JSON.parse(currentUserWorkflow) - } catch (error) { - logger.error('Failed to parse currentUserWorkflow', error) - throw new Error('Invalid currentUserWorkflow format') - } - } else { - const fromDb = await getCurrentWorkflowStateFromDb(workflowId) - workflowState = fromDb.workflowState - } - - const [permissionConfig, blockVisibility] = await Promise.all([ - workspaceId ? getUserPermissionConfig(context.userId, workspaceId) : null, - getBlockVisibilityForCopilot(context.userId, workspaceId), - ]) - - // Pre-validate credential and apiKey inputs before applying operations - // This filters out invalid credentials and apiKeys for hosted models - let operationsToApply = operations - const credentialErrors: ValidationError[] = [] - if (context?.userId) { - const { filteredOperations, errors: credErrors } = await preValidateCredentialInputs( + const result: ApplyWorkflowOperationsResult = await executeCopilotWorkflowUseCase( + context, + applyWorkflowOperations, + { + workflowId, operations, - { userId: context.userId, workspaceId }, - workflowState - ) - operationsToApply = filteredOperations - credentialErrors.push(...credErrors) - } - - // Apply operations directly to the workflow state - const { - state: modifiedWorkflowState, - validationErrors, - skippedItems, - } = await withBlockVisibility(blockVisibility, async () => - applyOperationsToWorkflowState(workflowState, operationsToApply, permissionConfig) - ) - - // Add credential validation errors - validationErrors.push(...credentialErrors) - - // Resolve credential/resource references against the workspace (Tier 2). - // Includes oauth-input credentials and only the active canonical member, so a - // credential "set in basic mode but unresolved in the dropdown" is caught. - let unresolvedReferences: WorkflowLintUnresolvedReference[] = [] - if (context?.userId) { - try { - unresolvedReferences = await collectUnresolvedReferences(modifiedWorkflowState, { - userId: context.userId, - workspaceId, - }) - // Back-compat: also surface unresolved references through the input-validation channel. - validationErrors.push( - ...unresolvedReferences.map((ref) => ({ - blockId: ref.blockId, - blockType: ref.blockType ?? 'unknown', - field: ref.field, - value: ref.value, - error: ref.reason, - })) - ) - } catch (error) { - logger.warn('Selector ID validation failed', { - error: toError(error).message, - }) - } - - // Resolve agent-block tool/skill references (custom tools, MCP servers, - // skills). A well-shaped entry whose id does not resolve is dropped at - // runtime, so the agent silently loses the tool/skill - surface it through - // the same lint + input-validation channels as credential/resource refs. - try { - const toolReferences = await collectUnresolvedAgentToolReferences(modifiedWorkflowState, { - userId: context.userId, - workspaceId, - }) - unresolvedReferences.push(...toolReferences) - validationErrors.push( - ...toolReferences.map((ref) => ({ - blockId: ref.blockId, - blockType: ref.blockType ?? 'agent', - field: ref.field, - value: ref.value, - error: ref.reason, - })) - ) - } catch (error) { - logger.warn('Agent tool/skill reference validation failed', { - error: toError(error).message, - }) - } - } - - // Validate the workflow state - const validation = validateWorkflowState(modifiedWorkflowState, { sanitize: true }) - - if (!validation.valid) { - logger.error('Edited workflow state is invalid', { - errors: validation.errors, - warnings: validation.warnings, - }) - throw new Error(`Invalid edited workflow: ${validation.errors.join('; ')}`) - } - - if (validation.warnings.length > 0) { - logger.warn('Edited workflow validation warnings', { - warnings: validation.warnings, - }) - } - - // Extract and persist custom tools to database (reuse workspaceId from selector validation) - if (context?.userId && workspaceId) { - try { - assertServerToolNotAborted(context) - const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState - const { saved, errors } = await extractAndPersistCustomTools( - finalWorkflowState, - workspaceId, - context.userId - ) - - if (saved > 0) { - logger.info(`Persisted ${saved} custom tool(s) to database`, { workflowId }) - } - - if (errors.length > 0) { - logger.warn('Some custom tools failed to persist', { errors, workflowId }) - } - } catch (error) { - logger.error('Failed to persist custom tools', { error, workflowId }) + ...(currentUserWorkflow + ? { baseGraph: parseCurrentUserWorkflow(currentUserWorkflow) } + : {}), + checkAborted: () => assertServerToolNotAborted(context), } - } else if (context?.userId && !workspaceId) { - logger.warn('Workflow has no workspaceId, skipping custom tools persistence', { - workflowId, - }) - } else { - logger.warn('No userId in context - skipping custom tools persistence', { workflowId }) - } - - logger.info('edit_workflow successfully applied operations', { - operationCount: operations.length, - blocksCount: Object.keys(modifiedWorkflowState.blocks).length, - edgesCount: modifiedWorkflowState.edges.length, - inputValidationErrors: validationErrors.length, - skippedItemsCount: skippedItems.length, - schemaValidationErrors: validation.errors.length, - validationWarnings: validation.warnings.length, - }) + ) - // Format validation errors for LLM feedback const inputErrors = - validationErrors.length > 0 - ? validationErrors.map((e) => `Block "${e.blockId}" (${e.blockType}): ${e.error}`) + result.inputValidationErrors.length > 0 + ? result.inputValidationErrors.map( + (error) => `Block "${error.blockId}" (${error.blockType}): ${error.error}` + ) : undefined - - // Split engine skipped items into genuine failures vs benign, self-healing - // deferrals. A deferred forward-reference edge (invalid_edge_target) is NOT - // a failure: the engine wires it automatically once its target block exists - // (this call or a later one) via pendingConnections. Surfacing it through - // the same "skipped" failure channel as real skips makes a literal model - // thrash (re-issuing a self-healing op). Keep them in separate result fields - // and preserve item.type/details so the prompt can branch on a - // machine-readable category instead of pattern-matching prose. - const mapSkippedItem = (item: (typeof skippedItems)[number]) => ({ - type: item.type, - operationType: item.operationType, - blockId: item.blockId, - reason: item.reason, - ...(item.details && { details: item.details }), - }) - - const genuineSkippedItems = skippedItems.filter((item) => !isDeferredSkippedItem(item)) - const deferredItems = skippedItems.filter((item) => isDeferredSkippedItem(item)) - const skippedDetails = - genuineSkippedItems.length > 0 ? genuineSkippedItems.map(mapSkippedItem) : undefined - const deferredDetails = deferredItems.length > 0 ? deferredItems.map(mapSkippedItem) : undefined - - // Persist the workflow state to the database - const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState - - const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({ - before: workflowState, - after: finalWorkflowState, - }) - - let layoutedBlocks = finalWorkflowState.blocks - - if (layoutBlockIds.length > 0 || resizedBlockIds.length > 0 || shiftSourceBlockIds.length > 0) { - try { - transferBlockHeights(workflowState.blocks, finalWorkflowState.blocks) - layoutedBlocks = applyTargetedLayout(finalWorkflowState.blocks, finalWorkflowState.edges, { - changedBlockIds: layoutBlockIds, - resizedBlockIds, - shiftSourceBlockIds, - horizontalSpacing: DEFAULT_HORIZONTAL_SPACING, - verticalSpacing: DEFAULT_VERTICAL_SPACING, - previousBlocks: workflowState.blocks, - }) - } catch (error) { - logger.warn('Targeted autolayout failed, using default positions', { - workflowId, - error: toError(error).message, - }) - } - } - - const workflowStateForDb = { - blocks: layoutedBlocks, - edges: finalWorkflowState.edges, - loops: generateLoopBlocks(layoutedBlocks as any), - parallels: generateParallelBlocks(layoutedBlocks as any), - lastSaved: Date.now(), - isDeployed: false, - } - - // Aggregate lint report: graph (sources/sinks/orphans/ports) + Tier-1 config - // (required + canonical-mode) + Tier-2 resolution (credential/resource IDs). - const graphLint = lintEditedWorkflowState(workflowStateForDb as any) - const fieldIssues = collectWorkflowFieldIssues(workflowStateForDb.blocks as any) - const workflowLint: WorkflowLintReport = { - ...graphLint, - fieldIssues, - unresolvedReferences, - notes: unresolvedReferences.length > 0 ? [UNRESOLVABLE_AT_LINT_NOTE] : [], - } - const workflowLintMessage = hasWorkflowLintIssues(workflowLint) - ? formatWorkflowLintMessage(workflowLint) + result.skipped.length > 0 ? result.skipped.map(mapSkippedItem) : undefined + const deferredDetails = + result.deferred.length > 0 ? result.deferred.map(mapSkippedItem) : undefined + const sanitizationWarnings = result.warnings.length > 0 ? result.warnings : undefined + const workflowLintMessage = hasWorkflowLintIssues(result.lint) + ? formatWorkflowLintMessage(result.lint) : undefined - assertServerToolNotAborted(context) - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForDb as any) - if (!saveResult.success) { - logger.error('Failed to persist workflow state to database', { - workflowId, - error: saveResult.error, - }) - throw new Error(`Failed to save workflow: ${saveResult.error}`) - } - - // Update workflow's lastSynced timestamp - assertServerToolNotAborted(context) - await db - .update(workflowTable) - .set({ - lastSynced: new Date(), - updatedAt: new Date(), - }) - .where(eq(workflowTable.id, workflowId)) - - logger.info('Workflow state persisted to database', { workflowId }) - - fetch(`${getSocketServerUrl()}/api/workflow-updated`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ workflowId }), - }).catch((error) => { - logger.warn('Failed to notify socket server of workflow update', { workflowId, error }) - }) - - const sanitizationWarnings = validation.warnings.length > 0 ? validation.warnings : undefined - return { success: true, - workflowId, - workflowName: workflowName ?? 'Workflow', - workflowState: { ...finalWorkflowState, blocks: layoutedBlocks }, - workflowLint, + workflowId: result.workflowId, + workflowName: result.workflowName || 'Workflow', + workflowState: result.graph, + workflowLint: result.lint, ...(workflowLintMessage && { workflowLintMessage }), ...(inputErrors && { inputValidationErrors: inputErrors, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index b5e7d943f69..c1e5d1e364b 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -40,11 +40,6 @@ import { compileDoc, getE2BDocFormat } from '@/lib/copilot/tools/server/files/do import { extractDocText, isExtractableDocExt } from '@/lib/copilot/tools/server/files/doc-extract' import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' import { isRenderableDocExt, renderDocToGrid } from '@/lib/copilot/tools/server/files/doc-render' -import { - collectWorkflowFieldIssues, - lintEditedWorkflowState, -} from '@/lib/copilot/tools/server/workflow/edit-workflow/lint' -import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/copilot/tools/server/workflow/edit-workflow/validation' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import { type FileReadResult, @@ -134,6 +129,8 @@ import { isImageFileType, resolveEffectiveMimeType } from '@/lib/uploads/utils/f import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' +import { collectWorkflowFieldIssues, lintEditedWorkflowState } from '@/lib/workflows/editing/lint' +import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/workflows/editing/validation' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { getSkillById } from '@/lib/workflows/skills/operations' diff --git a/apps/sim/lib/core/application/audit-source.ts b/apps/sim/lib/core/application/audit-source.ts new file mode 100644 index 00000000000..3835902a75d --- /dev/null +++ b/apps/sim/lib/core/application/audit-source.ts @@ -0,0 +1,17 @@ +import type { Principal } from '@sim/auth/principal' + +/** + * The surface a semantic audit row attributes a change to. + * + * Derived from the authenticated principal rather than hardcoded, because an + * operation's principal policy can widen: a `source` literal written when a use + * case had exactly one caller becomes a false audit row the moment a second one + * is admitted, and a false row is worse than no row. + * + * A delegated principal names its service (`copilot`, `executor`) — "which agent + * did this" is the distinction a reviewer reads the row for. Every other kind + * names its own credential class, which is already what `actor` records. + */ +export function principalAuditSource(principal: Principal): string { + return principal.kind === 'delegated' ? principal.serviceId : principal.kind +} diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 2197f839434..07529474ac1 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -52,6 +52,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', + /** The workspace's plan does not include a capability the request depends on. */ + 'WORKSPACE_PLAN_CAPABILITY_REQUIRED', ] as const export type ForbiddenDetailCode = (typeof FORBIDDEN_DETAIL_CODES)[number] @@ -89,6 +91,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record + /** Cancellation checkpoint, invoked before each step that commits work. */ + checkAborted?: () => void +} + +export interface ApplyWorkflowOperationsResult { + workflowId: string + workflowName: string + workspaceId: string + graph: { + blocks: Record + edges: WorkflowState['edges'] + loops: ReturnType + parallels: ReturnType + } + operationCount: number + applied: number + skipped: SkippedItem[] + deferred: SkippedItem[] + inputValidationErrors: ValidationError[] + lint: WorkflowLintReport + warnings: string[] + needsRedeployment: boolean +} + +/** + * The engine models a graph as an open record; the layout helpers want the + * canonical shape. One conversion, named, rather than a cast at each call. + */ +function asGraph(value: Record): Pick { + // double-cast-allowed: the edit engine models a graph as an open record; this is the one place it is read back as the canonical shape, and the layout helpers tolerate missing keys. + return value as unknown as Pick +} + +async function loadStoredGraph(workflowId: string): Promise> { + const normalized = await loadWorkflowFromNormalizedTables(workflowId) + if (!normalized) { + throw new OrchestrationError('validation', `Workflow ${workflowId} has no normalized state`) + } + const { state, warnings } = normalizeWorkflowState({ + blocks: normalized.blocks, + edges: normalized.edges, + loops: normalized.loops || {}, + parallels: normalized.parallels || {}, + }) + if (warnings.length > 0) { + logger.warn('Stored workflow state needed normalization before editing', { + workflowId, + warnings, + }) + } + // double-cast-allowed: the edit engine takes an open record; `normalizeWorkflowState` returns the canonical interface, which has no index signature. + return state as unknown as Record +} + +/** + * Applies the enable/disable slice of a batch in memory. + * + * A refusal is recorded as a skipped item rather than thrown, so the slice + * follows the same best-effort contract as the operations beside it and a single + * protected block cannot silently discard an otherwise valid batch. + */ +function applyBlockEnabledChanges( + blocks: Record, + changes: readonly WorkflowBlockEnabledChange[], + skippedItems: SkippedItem[] +): { blocks: Record; applied: number } { + let current = blocks + let applied = 0 + for (const change of changes) { + const decision = decideBlockEnablement(current, change.blockId, change.enabled) + if (decision.outcome === 'refused') { + skippedItems.push({ + type: decision.refusal.reason === 'not_found' ? 'block_not_found' : 'block_locked', + operationType: 'set_block_enabled', + blockId: change.blockId, + reason: decision.refusal.message, + }) + continue + } + if (decision.outcome === 'changed') { + current = decision.blocks + } + applied += 1 + } + return { blocks: current, applied } +} + +async function resolveBaseGraph( + principal: Principal, + input: ApplyWorkflowOperationsInput, + context: ActiveWorkflowApplicationContext +): Promise> { + if (input.baseGraph && principal.kind === 'delegated') return input.baseGraph + return loadStoredGraph(context.workflowId) +} + +/** + * The one semantic edit operation on a workflow graph. + * + * Best-effort at the operation level and atomic at the persistence level: the + * engine applies what it can to an in-memory graph and records the rest as typed + * skipped items, and exactly one write of the fully-resolved graph happens at the + * end, through the shared persistence primitive. A caller that needs all-or-nothing + * sets `atomic`, which decides between the in-memory apply and that single write. + * + * Copilot's `edit_workflow` tool and `POST /api/v2/workflows/{id}/operations` are + * both adapters over this; the tool is the only caller allowed to supply + * `baseGraph`. + */ +export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.applyOperations, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ApplyWorkflowOperationsInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }): Promise { + if (input.operations.length === 0) { + throw new OrchestrationError('validation', 'operations cannot be empty') + } + await requireMutableWorkflow(context.workflowId) + + if ( + operationsReferenceSimSandbox(input.operations) && + !(await hasWorkspaceSandboxAccess(context.workspaceId)) + ) { + throw new ForbiddenOperationError('WORKSPACE_PLAN_CAPABILITY_REQUIRED', MAX_PLAN_REQUIRED) + } + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const subjectUserId = attribution.attributedUserId + + input.checkAborted?.() + const baseGraph = await resolveBaseGraph(principal, input, context) + + const [permissionConfig, blockVisibility] = await Promise.all([ + getUserPermissionConfig(subjectUserId, context.workspaceId), + getBlockVisibility({ userId: subjectUserId, orgId: context.workspaceOrganizationId }), + ]) + + const { filteredOperations, errors: credentialErrors } = await preValidateCredentialInputs( + input.operations, + { userId: subjectUserId, workspaceId: context.workspaceId }, + baseGraph + ) + + const { + state: modifiedGraph, + validationErrors, + skippedItems, + } = await withBlockVisibility(blockVisibility, async () => + applyOperationsToWorkflowState(baseGraph, filteredOperations, permissionConfig) + ) + validationErrors.push(...credentialErrors) + + const enablement = applyBlockEnabledChanges( + modifiedGraph.blocks as Record, + input.blockEnabledChanges ?? [], + skippedItems + ) + modifiedGraph.blocks = enablement.blocks + + const unresolvedReferences: WorkflowLintUnresolvedReference[] = [] + for (const collect of [collectUnresolvedReferences, collectUnresolvedAgentToolReferences]) { + try { + const references = await collect(modifiedGraph, { + userId: subjectUserId, + workspaceId: context.workspaceId, + }) + unresolvedReferences.push(...references) + validationErrors.push( + ...references.map((reference) => ({ + blockId: reference.blockId, + blockType: reference.blockType ?? 'unknown', + field: reference.field, + value: reference.value, + error: reference.reason, + })) + ) + } catch (error) { + logger.warn('Reference resolution lint failed', { + workflowId: context.workflowId, + error: getErrorMessage(error), + }) + } + } + + const validation = validateWorkflowState(modifiedGraph, { sanitize: true }) + if (!validation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid edited workflow: ${validation.errors.join('; ')}` + ) + } + + const genuineSkippedItems = skippedItems.filter((item) => !isDeferredSkippedItem(item)) + const deferredItems = skippedItems.filter(isDeferredSkippedItem) + if (input.atomic && genuineSkippedItems.length > 0) { + throw new WorkflowOperationsNotAppliedError(genuineSkippedItems) + } + + const finalGraph = validation.sanitizedState || modifiedGraph + const blocks: Record = + input.layout === 'none' + ? (finalGraph.blocks as Record) + : layoutChangedBlocks(context.workflowId, asGraph(baseGraph), asGraph(finalGraph)) + + const graph = { + blocks, + edges: finalGraph.edges as WorkflowState['edges'], + loops: generateLoopBlocks(blocks), + parallels: generateParallelBlocks(blocks), + } + + const lint: WorkflowLintReport = { + ...lintEditedWorkflowState(graph), + fieldIssues: collectWorkflowFieldIssues(graph.blocks), + unresolvedReferences, + notes: unresolvedReferences.length > 0 ? [UNRESOLVABLE_AT_LINT_NOTE] : [], + } + + input.checkAborted?.() + const persisted = await replaceWorkflowNormalizedState({ + workflowId: context.workflowId, + workspaceId: context.workspaceId, + attributedUserId: subjectUserId, + state: { blocks: graph.blocks, edges: graph.edges }, + }) + + const applied = filteredOperations.length - genuineSkippedItems.length + enablement.applied + + logger.info('Applied workflow operations', { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + operationCount: input.operations.length, + applied, + skipped: genuineSkippedItems.length, + principalKind: principal.kind, + }) + + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + graph, + operationCount: input.operations.length, + applied: Math.max(applied, 0), + skipped: genuineSkippedItems, + deferred: deferredItems, + inputValidationErrors: validationErrors, + lint, + warnings: [...validation.warnings, ...persisted.warnings], + needsRedeployment: await checkNeedsRedeployment(context.workflowId), + } + }, + projectAudit: ({ principal, context, result }) => ({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflowName, + description: `Applied ${result.operationCount} edit operation(s) to workflow "${result.workflowName}"`, + metadata: { + op: 'apply_operations', + operationCount: result.operationCount, + appliedCount: result.applied, + skippedCount: result.skipped.length, + blocksCount: Object.keys(result.graph.blocks).length, + edgesCount: result.graph.edges.length, + source: principalAuditSource(principal), + }, + }), + afterSuccess: ({ context }) => notifyWorkflowUpdated(context.workflowId), +}) + +/** + * Nudges only the blocks this batch touched, leaving the rest of the canvas + * where the user put it. A layout failure is never fatal: the graph is already + * correct, only its positions are less tidy. + */ +function layoutChangedBlocks( + workflowId: string, + before: Pick, + after: Pick +): Record { + const { layoutBlockIds, resizedBlockIds, shiftSourceBlockIds } = getTargetedLayoutImpact({ + before, + after, + }) + if ( + layoutBlockIds.length === 0 && + resizedBlockIds.length === 0 && + shiftSourceBlockIds.length === 0 + ) { + return after.blocks + } + try { + transferBlockHeights(before.blocks, after.blocks) + return applyTargetedLayout(after.blocks, after.edges, { + changedBlockIds: layoutBlockIds, + resizedBlockIds, + shiftSourceBlockIds, + horizontalSpacing: DEFAULT_HORIZONTAL_SPACING, + verticalSpacing: DEFAULT_VERTICAL_SPACING, + previousBlocks: before.blocks, + }) as Record + } catch (error) { + logger.warn('Targeted autolayout failed, using supplied positions', { + workflowId, + error: toError(error).message, + }) + return after.blocks + } +} diff --git a/apps/sim/lib/workflows/application/context.ts b/apps/sim/lib/workflows/application/context.ts index 49b7c657468..6f9ab16ce23 100644 --- a/apps/sim/lib/workflows/application/context.ts +++ b/apps/sim/lib/workflows/application/context.ts @@ -58,6 +58,41 @@ export async function resolveActiveWorkflowApplicationContext(input: { return { ...workspaceContext, ...canonicalWorkflow, workspaceId: workspaceContext.workspaceId } } +/** + * Canonical context for a workflow that may be archived. + * + * Separate from {@link resolveActiveWorkflowApplicationContext}, which excludes + * archived rows by construction — a restore has to reach exactly the rows that + * one hides. + */ +export async function resolveArchivedWorkflowApplicationContext(input: { + workflowId: string + assertedWorkspaceId?: string +}): Promise { + const [canonicalWorkflow] = await db + .select({ + workflowId: workflow.id, + workflow, + workspaceId: workflow.workspaceId, + }) + .from(workflow) + .where(eq(workflow.id, input.workflowId)) + .limit(1) + + if ( + !canonicalWorkflow?.workspaceId || + (input.assertedWorkspaceId !== undefined && + input.assertedWorkspaceId !== canonicalWorkflow.workspaceId) + ) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + const workspaceContext = await loadActiveWorkspaceApplicationContext( + canonicalWorkflow.workspaceId + ) + if (!workspaceContext) throw new OrchestrationError('not_found', 'Workflow not found') + return { ...workspaceContext, ...canonicalWorkflow, workspaceId: workspaceContext.workspaceId } +} + async function resolveCanonicalRunWorkflowId(runId: string): Promise { const [logRows, pausedRows, resumeRows] = await Promise.all([ db diff --git a/apps/sim/lib/workflows/application/duplicate-workflow.ts b/apps/sim/lib/workflows/application/duplicate-workflow.ts index fd6f01cf884..fb171909bbf 100644 --- a/apps/sim/lib/workflows/application/duplicate-workflow.ts +++ b/apps/sim/lib/workflows/application/duplicate-workflow.ts @@ -1,19 +1,30 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { db } from '@sim/db' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { + resolveWorkflowFolderPath, + workflowFolderPathForId, +} from '@/lib/workflows/application/workflow-folders' import { duplicateWorkflow as duplicateWorkflowRecord } from '@/lib/workflows/persistence/duplicate' export interface DuplicateWorkflowInput { sourceWorkflowId: string assertedWorkspaceId?: string - folderId: string | null - name: string + /** Canonical destination folder. Mutually exclusive with `folderPath`. */ + folderId?: string | null + /** Destination folder by path, resolved against the workspace's folder tree. */ + folderPath?: string + /** Defaults to the source workflow's name, deduplicated within the destination folder. */ + name?: string } export const duplicateWorkflow = defineAuthorizedWorkflowUseCase({ @@ -24,20 +35,40 @@ export const duplicateWorkflow = defineAuthorizedWorkflowUseCase({ assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), }), async execute({ principal, input, context }) { + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Provide either folderPath or folderId, not both') + } + const resolution = + input.folderPath === undefined + ? { + folderId: input.folderId === undefined ? context.workflow.folderId : input.folderId, + index: await loadActiveFolderPathIndex(context.workspaceId, 'workflow', undefined, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }), + } + : await resolveWorkflowFolderPath(context.workspaceId, input.folderPath) + if (resolution.folderId && !resolution.index.pathById.has(resolution.folderId)) { + throw new OrchestrationError('not_found', 'Folder not found') + } + const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) - return db.transaction((tx) => + const duplicated = await db.transaction((tx) => duplicateWorkflowRecord({ sourceWorkflowId: context.workflowId, userId: attribution.attributedUserId, workspaceId: context.workspaceId, - folderId: input.folderId, - name: input.name, + folderId: resolution.folderId, + name: input.name ?? context.workflow.name, requestId: generateRequestId(), tx, }) ) + return { + ...duplicated, + folderPath: workflowFolderPathForId(resolution.index, duplicated.folderId), + } }, projectAudit: ({ context, result }) => ({ action: AuditAction.WORKFLOW_DUPLICATED, diff --git a/apps/sim/lib/workflows/application/list-workflows.ts b/apps/sim/lib/workflows/application/list-workflows.ts index 9257735ec80..541aee370cf 100644 --- a/apps/sim/lib/workflows/application/list-workflows.ts +++ b/apps/sim/lib/workflows/application/list-workflows.ts @@ -17,6 +17,7 @@ const logger = createLogger('ListWorkflows') export interface ListWorkflowsInput { workspaceId: string folderPath?: string + scope: 'active' | 'archived' deployedOnly: boolean search?: string sortBy: WorkflowSortBy @@ -49,6 +50,7 @@ export const listWorkflows = defineAuthorizedWorkflowUseCase({ const page = await listWorkspaceWorkflows({ workspaceId: context.workspaceId, folderId: folderFilter.kind === 'folder' ? folderFilter.folderId : undefined, + scope: input.scope, deployedOnly: input.deployedOnly, search: input.search, sortBy: input.sortBy, diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.ts index 62c6acc0e5c..f848a464f17 100644 --- a/apps/sim/lib/workflows/application/move-workflows-bulk.ts +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.ts @@ -15,6 +15,7 @@ import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/aut import { resolveActiveWorkspaceApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { requireWorkflowTransition } from '@/lib/workflows/application/transition-result' +import { resolveWorkflowFolderPath } from '@/lib/workflows/application/workflow-folders' import { updateWorkflowRecord } from '@/lib/workflows/orchestration' const MAX_BULK_WORKFLOW_MOVES = 100 @@ -22,7 +23,10 @@ const MAX_BULK_WORKFLOW_MOVES = 100 export interface MoveWorkflowsBulkInput { workspaceId: string workflowIds: string[] - folderId: string | null + /** Canonical destination folder. Mutually exclusive with `folderPath`. */ + folderId?: string | null + /** Destination folder by path, resolved against the workspace's folder tree. */ + folderPath?: string } interface MovedWorkflow { @@ -68,7 +72,14 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ resolveContext: ({ input }: { input: MoveWorkflowsBulkInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), async execute({ principal, input, context }): Promise { + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Provide either folderPath or folderId, not both') + } const workflowIds = normalizeWorkflowIds(input.workflowIds) + const folderId = + input.folderPath === undefined + ? (input.folderId ?? null) + : (await resolveWorkflowFolderPath(context.workspaceId, input.folderPath)).folderId const rows = await db .select({ id: workflow.id, @@ -99,7 +110,7 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ } try { - await requireMutable(workflowId, input.folderId) + await requireMutable(workflowId, folderId) const changed = await db.transaction(async (tx) => { const [current] = await tx .select({ @@ -125,7 +136,7 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ workspaceId: context.workspaceId, currentName: current.name, currentFolderId: current.folderId, - folderId: input.folderId, + folderId, tx, }) requireWorkflowTransition(transition, 'Failed to move workflow') @@ -144,7 +155,7 @@ export const moveWorkflowsBulk = defineAuthorizedWorkflowUseCase({ } } - return { moved, failed, folderId: input.folderId, changes } + return { moved, failed, folderId, changes } }, projectAudit: ({ result }) => result.changes.map((change) => ({ diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 5cfae2226f7..f23f9f8010b 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -69,6 +69,24 @@ export const workflowOperations = { workspaceApiKey: 'allow', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + replaceState: defineWorkspaceOperation({ + id: 'workflows.state.replace', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), + applyOperations: defineWorkspaceOperation({ + id: 'workflows.operations.apply', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), + restore: defineWorkspaceOperation({ + id: 'workflows.restore', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), updatePolicy: defineWorkspaceOperation({ id: 'workflows.policy.update', minimumRole: 'admin', @@ -78,8 +96,8 @@ export const workflowOperations = { applyVariableOperations: defineWorkspaceOperation({ id: 'workflows.variables.apply_operations', minimumRole: 'write', - workspaceApiKey: 'deny', - ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), setBlockEnabled: defineWorkspaceOperation({ id: 'workflows.blocks.set_enabled', @@ -90,8 +108,8 @@ export const workflowOperations = { moveBulk: defineWorkspaceOperation({ id: 'workflows.bulk.move', minimumRole: 'write', - workspaceApiKey: 'deny', - ...COPILOT_WORKFLOW_PRINCIPAL_POLICY, + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), createVfsFolders: defineWorkspaceOperation({ id: 'workflows.vfs.folders.create', diff --git a/apps/sim/lib/workflows/application/read-workflow-graph.ts b/apps/sim/lib/workflows/application/read-workflow-graph.ts new file mode 100644 index 00000000000..d352567af9e --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-graph.ts @@ -0,0 +1,58 @@ +import type { Principal } from '@sim/auth/principal' +import type { BlockState, Variable, WorkflowState } from '@sim/workflow-types/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { loadWorkflowReadSnapshot } from '@/lib/workflows/queries' + +export interface ReadWorkflowGraphInput { + workflowId: string + assertedWorkspaceId?: string +} + +export interface ReadWorkflowGraphResult { + workflowId: string + workspaceId: string + blocks: Record + edges: WorkflowState['edges'] + loops: WorkflowState['loops'] + parallels: WorkflowState['parallels'] + variables: Record +} + +/** + * Reads a workflow's editable draft graph, unsanitized. + * + * The same semantic operation as `readWorkflow` — "read this workflow" — and the + * same loader, so the two reads cannot disagree about migrate-on-read. + * + * Records **no** semantic audit, deliberately. This is the pollable read; the + * audited, portable, sanitized one is `workflows.export`, and auditing here + * would force `headSafe: false` and make the endpoint unusable for polling. + */ +export const readWorkflowGraph = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.read, + resolveContext: ({ principal, input }: { principal: Principal; input: ReadWorkflowGraphInput }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ context }): Promise { + const snapshot = await loadWorkflowReadSnapshot(context.workflowId, context.workspaceId) + if (!snapshot.workflowRecord || !snapshot.normalizedData) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + const variables = snapshot.workflowRecord.variables + return { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + blocks: snapshot.normalizedData.blocks as Record, + edges: (snapshot.normalizedData.edges ?? []) as WorkflowState['edges'], + loops: snapshot.normalizedData.loops ?? {}, + parallels: snapshot.normalizedData.parallels ?? {}, + variables: (variables as Record | null) ?? {}, + } + }, +}) diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.ts b/apps/sim/lib/workflows/application/replace-workflow-state.ts new file mode 100644 index 00000000000..255f1194b6d --- /dev/null +++ b/apps/sim/lib/workflows/application/replace-workflow-state.ts @@ -0,0 +1,125 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { principalAuditSource } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { requireMutableWorkflow } from '@/lib/workflows/application/workflow-mutability' +import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' +import { replaceWorkflowNormalizedState } from '@/lib/workflows/persistence/replace-normalized-state' +import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' + +const logger = createLogger('ReplaceWorkflowState') + +export interface ReplaceWorkflowStateInput { + workflowId: string + assertedWorkspaceId?: string + blocks: Record + edges: WorkflowState['edges'] + /** Omitted leaves the stored variables untouched. */ + variables?: Record +} + +export interface ReplaceWorkflowStateResult { + workflowId: string + workflowName: string + workspaceId: string + blocksCount: number + edgesCount: number + warnings: string[] + needsRedeployment: boolean +} + +/** + * Replaces a workflow's editable draft graph wholesale. + * + * Semantic validation runs **before** the write, not because the persistence + * layer would accept nonsense but because it would fault on it — a well-formed + * body describing an impossible graph would otherwise be a caller-reachable 500. + * + * Nothing here touches deployments, schedules, or webhooks: those are only + * changed on the deploy/undeploy path. The one observable consequence is that + * the live deployment now differs from the draft. + */ +export const replaceWorkflowState = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.replaceState, + resolveContext: ({ + principal, + input, + }: { + principal: Principal + input: ReplaceWorkflowStateInput + }) => + resolveActiveWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, input, context }): Promise { + await requireMutableWorkflow(context.workflowId) + + const candidate = { + blocks: input.blocks, + edges: input.edges, + loops: {}, + parallels: {}, + } + const validation = validateWorkflowState(candidate, { sanitize: true }) + if (!validation.valid) { + throw new OrchestrationError( + 'validation', + `Invalid workflow state: ${validation.errors.join('; ')}` + ) + } + const sanitized = validation.sanitizedState ?? candidate + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const persisted = await replaceWorkflowNormalizedState({ + workflowId: context.workflowId, + workspaceId: context.workspaceId, + attributedUserId: attribution.attributedUserId, + state: { + blocks: sanitized.blocks as Record, + edges: sanitized.edges as WorkflowState['edges'], + variables: input.variables, + }, + }) + + logger.info('Replaced workflow state', { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + principalKind: principal.kind, + }) + + return { + workflowId: context.workflowId, + workflowName: context.workflow.name, + workspaceId: context.workspaceId, + blocksCount: Object.keys(persisted.state.blocks).length, + edgesCount: persisted.state.edges.length, + warnings: [...validation.warnings, ...persisted.warnings], + needsRedeployment: await checkNeedsRedeployment(context.workflowId), + } + }, + projectAudit: ({ principal, context, result }) => ({ + action: AuditAction.WORKFLOW_UPDATED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflowName, + description: `Replaced the draft graph of workflow "${result.workflowName}"`, + metadata: { + op: 'replace_state', + blocksCount: result.blocksCount, + edgesCount: result.edgesCount, + warnings: result.warnings, + source: principalAuditSource(principal), + }, + }), + afterSuccess: ({ context }) => notifyWorkflowUpdated(context.workflowId), +}) diff --git a/apps/sim/lib/workflows/application/restore-workflow.ts b/apps/sim/lib/workflows/application/restore-workflow.ts new file mode 100644 index 00000000000..146e5731935 --- /dev/null +++ b/apps/sim/lib/workflows/application/restore-workflow.ts @@ -0,0 +1,93 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { + assertFolderMutable, + FolderLockedError, + WorkflowLockedError, +} from '@sim/platform-authz/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { notifyWorkflowUpdated } from '@/lib/realtime/notify' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveArchivedWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { workflowFolderPathForId } from '@/lib/workflows/application/workflow-folders' +import { restoreWorkflow as restoreWorkflowRecord } from '@/lib/workflows/lifecycle' + +const logger = createLogger('RestoreWorkflow') + +export interface RestoreWorkflowInput { + workflowId: string + assertedWorkspaceId?: string +} + +/** + * Brings an archived workflow, and the schedules, webhooks, MCP tools, and chats + * archived alongside it, back to active. + * + * Calls the lifecycle primitive rather than `performRestoreWorkflow`: that + * orchestration records its own audit row keyed on a bare `userId`, which cannot + * represent a workspace-key or delegated principal. Audit is projected here + * instead, from the authoritative restored row. + */ +export const restoreWorkflow = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.restore, + resolveContext: ({ principal, input }: { principal: Principal; input: RestoreWorkflowInput }) => + resolveArchivedWorkflowApplicationContext({ + workflowId: input.workflowId, + assertedWorkspaceId: assertedWorkflowWorkspaceId(principal, input.assertedWorkspaceId), + }), + async execute({ principal, context }) { + if (context.workflow.locked) { + throw new OrchestrationError('locked', 'Workflow is locked') + } + try { + await assertFolderMutable(context.workflow.folderId) + } catch (error) { + if (error instanceof FolderLockedError || error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } + + const restored = await restoreWorkflowRecord(context.workflowId, { + requestId: generateRequestId(), + }) + if (!restored.workflow) { + throw new OrchestrationError('not_found', 'Workflow not found') + } + if (!restored.restored) { + throw new OrchestrationError('conflict', 'Workflow is not archived') + } + + const folderIndex = await loadActiveFolderPathIndex( + context.workspaceId, + 'workflow', + undefined, + { maxRows: MAX_FOLDERS_PER_WORKSPACE } + ) + logger.info('Restored workflow', { + workflowId: context.workflowId, + workspaceId: context.workspaceId, + principalKind: principal.kind, + }) + return { + workflow: restored.workflow, + workspaceId: context.workspaceId, + folderPath: workflowFolderPathForId(folderIndex, restored.workflow.folderId), + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.WORKFLOW_RESTORED, + resourceType: AuditResourceType.WORKFLOW, + resourceId: context.workflowId, + resourceName: result.workflow.name, + description: `Restored workflow "${result.workflow.name}"`, + metadata: { workflowName: result.workflow.name, workspaceId: context.workspaceId }, + }), + afterSuccess: ({ context }) => notifyWorkflowUpdated(context.workflowId), +}) diff --git a/apps/sim/lib/workflows/application/update-workflow-content.ts b/apps/sim/lib/workflows/application/update-workflow-content.ts index d654907c506..6db310df020 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.ts @@ -3,18 +3,23 @@ import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' import { and, eq, isNull } from 'drizzle-orm' +import { principalAuditSource } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' +import { requireMutableWorkflow } from '@/lib/workflows/application/workflow-mutability' +import { + type BlockEnablementRefusal, + decideBlockEnablement, +} from '@/lib/workflows/editing/block-enablement' import { loadWorkflowFromNormalizedTables, saveWorkflowToNormalizedTables, @@ -23,22 +28,21 @@ import { const logger = createLogger('UpdateWorkflowContent') const MAX_WORKFLOW_VARIABLE_OPERATIONS = 100 +/** How each protection refusal is classified when a single block toggle is the whole request. */ +const BLOCK_ENABLEMENT_REFUSAL_CODES: Record< + BlockEnablementRefusal['reason'], + 'not_found' | 'locked' | 'validation' +> = { + not_found: 'not_found', + locked: 'locked', + disabled_ancestor: 'validation', +} + interface WorkflowContentInput { workflowId: string assertedWorkspaceId?: string } -async function requireMutableWorkflow(workflowId: string): Promise { - try { - await assertWorkflowMutable(workflowId) - } catch (error) { - if (error instanceof WorkflowLockedError) { - throw new OrchestrationError('locked', error.message) - } - throw error - } -} - function resolveWorkflowContentContext({ principal, input, @@ -203,7 +207,7 @@ export const applyWorkflowVariableOperations = defineAuthorizedWorkflowUseCase({ return { updated: Object.keys(transformed.variables).length, changed: true } }) }, - projectAudit: ({ input, context, result }) => + projectAudit: ({ principal, input, context, result }) => result.changed ? { action: AuditAction.WORKFLOW_VARIABLES_UPDATED, @@ -211,59 +215,16 @@ export const applyWorkflowVariableOperations = defineAuthorizedWorkflowUseCase({ resourceId: context.workflowId, resourceName: context.workflow.name, description: 'Updated workflow variables', - metadata: { operationCount: input.operations.length, source: 'copilot' }, + metadata: { + operationCount: input.operations.length, + source: principalAuditSource(principal), + }, } : [], afterSuccess: ({ context, result }) => result.changed ? notifyWorkflowUpdated(context.workflowId) : undefined, }) -function isBlockProtected(blockId: string, blocksById: Record): boolean { - const block = blocksById[blockId] - if (!block) return false - if (block.locked) return true - - const visited = new Set() - let parentId = block.data?.parentId - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - if (blocksById[parentId]?.locked) return true - parentId = blocksById[parentId]?.data?.parentId - } - return false -} - -function hasDisabledAncestor(blockId: string, blocksById: Record): boolean { - const visited = new Set() - let parentId = blocksById[blockId]?.data?.parentId - while (parentId && !visited.has(parentId)) { - visited.add(parentId) - const parent = blocksById[parentId] - if (!parent) return false - if (parent.enabled === false) return true - parentId = parent.data?.parentId - } - return false -} - -function findDescendants(containerId: string, blocksById: Record): string[] { - const descendants: string[] = [] - const stack = [containerId] - const visited = new Set() - while (stack.length > 0) { - const current = stack.pop()! - if (visited.has(current)) continue - visited.add(current) - for (const [blockId, block] of Object.entries(blocksById)) { - if (block.data?.parentId === current) { - descendants.push(blockId) - stack.push(blockId) - } - } - } - return descendants -} - export interface SetWorkflowBlockEnabledInput extends WorkflowContentInput { blockId: string enabled: boolean @@ -303,50 +264,27 @@ export const setWorkflowBlockEnabled = defineAuthorizedWorkflowUseCase({ parallels: normalized.parallels || {}, lastSaved: Date.now(), } - const targetBlock = currentState.blocks[input.blockId] - if (!targetBlock) { - throw new OrchestrationError( - 'not_found', - `Block ${input.blockId} not found in workflow ${context.workflowId}` - ) - } - if (isBlockProtected(input.blockId, currentState.blocks)) { + const decision = decideBlockEnablement(currentState.blocks, input.blockId, input.enabled) + if (decision.outcome === 'refused') { throw new OrchestrationError( - 'locked', - `Block ${input.blockId} is locked or inside a locked container and cannot be updated` + BLOCK_ENABLEMENT_REFUSAL_CODES[decision.refusal.reason], + decision.refusal.reason === 'not_found' + ? `Block ${input.blockId} not found in workflow ${context.workflowId}` + : decision.refusal.message ) } - if (input.enabled && hasDisabledAncestor(input.blockId, currentState.blocks)) { - throw new OrchestrationError( - 'validation', - `Cannot enable block ${input.blockId} while one of its parent containers is disabled. Enable the parent first.` - ) - } - - const affectedBlockIds = new Set([input.blockId]) - if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { - for (const descendantId of findDescendants(input.blockId, currentState.blocks)) { - if (!isBlockProtected(descendantId, currentState.blocks)) { - affectedBlockIds.add(descendantId) - } - } - } - if (targetBlock.enabled === input.enabled) { + if (decision.outcome === 'unchanged') { return { changed: false, workflowName: active.name, - affectedBlockIds: [input.blockId], + affectedBlockIds: decision.affectedBlockIds, state: currentState, } } - const nextBlocks = { ...currentState.blocks } - for (const blockId of affectedBlockIds) { - nextBlocks[blockId] = { ...nextBlocks[blockId], enabled: input.enabled } - } const nextState: WorkflowState = { ...currentState, - blocks: nextBlocks, + blocks: decision.blocks, lastSaved: Date.now(), } const saveResult = await saveWorkflowToNormalizedTables(context.workflowId, nextState, tx) @@ -368,12 +306,12 @@ export const setWorkflowBlockEnabled = defineAuthorizedWorkflowUseCase({ return { changed: true, workflowName: active.name, - affectedBlockIds: [...affectedBlockIds], + affectedBlockIds: decision.affectedBlockIds, state: nextState, } }) }, - projectAudit: ({ input, context, result }) => + projectAudit: ({ principal, input, context, result }) => result.changed ? { action: AuditAction.WORKFLOW_UPDATED, @@ -386,7 +324,7 @@ export const setWorkflowBlockEnabled = defineAuthorizedWorkflowUseCase({ blockId: input.blockId, enabled: input.enabled, affectedBlockIds: result.affectedBlockIds, - source: 'copilot', + source: principalAuditSource(principal), }, } : [], diff --git a/apps/sim/lib/workflows/application/workflow-mutability.ts b/apps/sim/lib/workflows/application/workflow-mutability.ts new file mode 100644 index 00000000000..5f5f9897c5c --- /dev/null +++ b/apps/sim/lib/workflows/application/workflow-mutability.ts @@ -0,0 +1,14 @@ +import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** Refuses a mutation against a locked workflow as the `423` every surface renders. */ +export async function requireMutableWorkflow(workflowId: string): Promise { + try { + await assertWorkflowMutable(workflowId) + } catch (error) { + if (error instanceof WorkflowLockedError) { + throw new OrchestrationError('locked', error.message) + } + throw error + } +} diff --git a/apps/sim/lib/workflows/editing/block-enablement.ts b/apps/sim/lib/workflows/editing/block-enablement.ts new file mode 100644 index 00000000000..dc8d92a21f9 --- /dev/null +++ b/apps/sim/lib/workflows/editing/block-enablement.ts @@ -0,0 +1,128 @@ +import type { BlockState } from '@sim/workflow-types/workflow' + +/** Whether a block, or any container above it, is locked against edits. */ +export function isBlockProtected(blockId: string, blocksById: Record): boolean { + const block = blocksById[blockId] + if (!block) return false + if (block.locked) return true + + const visited = new Set() + let parentId = block.data?.parentId + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + if (blocksById[parentId]?.locked) return true + parentId = blocksById[parentId]?.data?.parentId + } + return false +} + +/** Whether any container above a block is disabled, which keeps the block from running. */ +export function hasDisabledAncestor( + blockId: string, + blocksById: Record +): boolean { + const visited = new Set() + let parentId = blocksById[blockId]?.data?.parentId + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = blocksById[parentId] + if (!parent) return false + if (parent.enabled === false) return true + parentId = parent.data?.parentId + } + return false +} + +/** Every block nested, at any depth, inside a container. */ +export function findDescendants( + containerId: string, + blocksById: Record +): string[] { + const descendants: string[] = [] + const stack = [containerId] + const visited = new Set() + while (stack.length > 0) { + const current = stack.pop()! + if (visited.has(current)) continue + visited.add(current) + for (const [blockId, block] of Object.entries(blocksById)) { + if (block.data?.parentId === current) { + descendants.push(blockId) + stack.push(blockId) + } + } + } + return descendants +} + +export type BlockEnablementRefusal = + | { reason: 'not_found'; message: string } + | { reason: 'locked'; message: string } + | { reason: 'disabled_ancestor'; message: string } + +export type BlockEnablementDecision = + | { outcome: 'refused'; refusal: BlockEnablementRefusal } + | { outcome: 'unchanged'; affectedBlockIds: string[] } + | { outcome: 'changed'; blocks: Record; affectedBlockIds: string[] } + +/** + * Decides what enabling or disabling one block does to a graph. + * + * Pure, and the single source of truth for the three protection rules — a + * locked block or locked container cannot be toggled, a block cannot be enabled + * while a container above it is disabled, and toggling a loop or parallel + * cascades to its unlocked descendants. Both the dedicated + * `workflows.blocks.set_enabled` use case and the `setBlockEnabled` slice of a + * `workflows.operations.apply` batch call it, so the two cannot drift into + * disagreeing about what is protected. + */ +export function decideBlockEnablement( + blocks: Record, + blockId: string, + enabled: boolean +): BlockEnablementDecision { + const targetBlock = blocks[blockId] + if (!targetBlock) { + return { + outcome: 'refused', + refusal: { reason: 'not_found', message: `Block ${blockId} not found` }, + } + } + if (isBlockProtected(blockId, blocks)) { + return { + outcome: 'refused', + refusal: { + reason: 'locked', + message: `Block ${blockId} is locked or inside a locked container and cannot be updated`, + }, + } + } + if (enabled && hasDisabledAncestor(blockId, blocks)) { + return { + outcome: 'refused', + refusal: { + reason: 'disabled_ancestor', + message: `Cannot enable block ${blockId} while one of its parent containers is disabled. Enable the parent first.`, + }, + } + } + + const affectedBlockIds = new Set([blockId]) + if (targetBlock.type === 'loop' || targetBlock.type === 'parallel') { + for (const descendantId of findDescendants(blockId, blocks)) { + if (!isBlockProtected(descendantId, blocks)) { + affectedBlockIds.add(descendantId) + } + } + } + + if (targetBlock.enabled === enabled) { + return { outcome: 'unchanged', affectedBlockIds: [blockId] } + } + + const nextBlocks = { ...blocks } + for (const affectedId of affectedBlockIds) { + nextBlocks[affectedId] = { ...nextBlocks[affectedId], enabled } + } + return { outcome: 'changed', blocks: nextBlocks, affectedBlockIds: [...affectedBlockIds] } +} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts b/apps/sim/lib/workflows/editing/builders.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts rename to apps/sim/lib/workflows/editing/builders.test.ts index baf77b90ea3..b5fa680bf74 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.test.ts +++ b/apps/sim/lib/workflows/editing/builders.test.ts @@ -7,7 +7,7 @@ import { createBlockFromParams, filterDisallowedTools, normalizeSubblockValue, -} from '@/lib/copilot/tools/server/workflow/edit-workflow/builders' +} from '@/lib/workflows/editing/builders' const { mockIsIntegrationDeploymentAvailable } = vi.hoisted(() => ({ mockIsIntegrationDeploymentAvailable: vi.fn(() => true), diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts b/apps/sim/lib/workflows/editing/builders.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts rename to apps/sim/lib/workflows/editing/builders.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts b/apps/sim/lib/workflows/editing/engine.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts rename to apps/sim/lib/workflows/editing/engine.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.test.ts b/apps/sim/lib/workflows/editing/lint.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.test.ts rename to apps/sim/lib/workflows/editing/lint.test.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.ts b/apps/sim/lib/workflows/editing/lint.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.ts rename to apps/sim/lib/workflows/editing/lint.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts b/apps/sim/lib/workflows/editing/operations.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.test.ts rename to apps/sim/lib/workflows/editing/operations.test.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts b/apps/sim/lib/workflows/editing/operations.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.ts rename to apps/sim/lib/workflows/editing/operations.ts diff --git a/apps/sim/lib/copilot/sim-sandbox-projection.test.ts b/apps/sim/lib/workflows/editing/sandbox-projection.test.ts similarity index 88% rename from apps/sim/lib/copilot/sim-sandbox-projection.test.ts rename to apps/sim/lib/workflows/editing/sandbox-projection.test.ts index c576b3ee409..fd803088901 100644 --- a/apps/sim/lib/copilot/sim-sandbox-projection.test.ts +++ b/apps/sim/lib/workflows/editing/sandbox-projection.test.ts @@ -1,7 +1,7 @@ /** @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { operationsReferenceSimSandbox } from '@/lib/copilot/sim-sandbox-projection' +import { operationsReferenceSimSandbox } from '@/lib/workflows/editing/sandbox-projection' describe('operationsReferenceSimSandbox', () => { it('detects add and edit inputs that set or clear sandboxId', () => { diff --git a/apps/sim/lib/workflows/editing/sandbox-projection.ts b/apps/sim/lib/workflows/editing/sandbox-projection.ts new file mode 100644 index 00000000000..8f313907808 --- /dev/null +++ b/apps/sim/lib/workflows/editing/sandbox-projection.ts @@ -0,0 +1,9 @@ +/** Whether an edit-operation batch tries to set or clear a Function block's `sandboxId`. */ +export function operationsReferenceSimSandbox( + operations: ReadonlyArray<{ params?: Record }> +): boolean { + return operations.some((operation) => { + const inputs = operation.params?.inputs + return Boolean(inputs && typeof inputs === 'object' && 'sandboxId' in inputs) + }) +} diff --git a/apps/sim/lib/copilot/validation/selector-validator.test.ts b/apps/sim/lib/workflows/editing/selector-validator.test.ts similarity index 100% rename from apps/sim/lib/copilot/validation/selector-validator.test.ts rename to apps/sim/lib/workflows/editing/selector-validator.test.ts diff --git a/apps/sim/lib/copilot/validation/selector-validator.ts b/apps/sim/lib/workflows/editing/selector-validator.ts similarity index 100% rename from apps/sim/lib/copilot/validation/selector-validator.ts rename to apps/sim/lib/workflows/editing/selector-validator.ts diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts b/apps/sim/lib/workflows/editing/types.ts similarity index 81% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts rename to apps/sim/lib/workflows/editing/types.ts index c12e16c4add..65238f9532e 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts +++ b/apps/sim/lib/workflows/editing/types.ts @@ -28,28 +28,37 @@ export interface ValidationError { error: string } +/** + * Every reason the engine can decline one operation. + * + * An array rather than a bare union so the public contract can publish the set + * with `z.enum(...)` and a new reason cannot reach the wire undocumented. + */ +export const WORKFLOW_SKIPPED_ITEM_TYPES = [ + 'block_not_found', + 'invalid_block_type', + 'block_not_allowed', + 'block_locked', + 'tool_not_allowed', + 'invalid_edge_target', + 'invalid_edge_source', + 'invalid_edge_scope', + 'invalid_source_handle', + 'invalid_target_handle', + 'invalid_subblock_field', + 'missing_required_params', + 'invalid_subflow_parent', + 'nested_subflow_not_allowed', + 'duplicate_block_name', + 'reserved_block_name', + 'duplicate_trigger', + 'duplicate_single_instance_block', +] as const + /** * Types of items that can be skipped during operation application */ -export type SkippedItemType = - | 'block_not_found' - | 'invalid_block_type' - | 'block_not_allowed' - | 'block_locked' - | 'tool_not_allowed' - | 'invalid_edge_target' - | 'invalid_edge_source' - | 'invalid_edge_scope' - | 'invalid_source_handle' - | 'invalid_target_handle' - | 'invalid_subblock_field' - | 'missing_required_params' - | 'invalid_subflow_parent' - | 'nested_subflow_not_allowed' - | 'duplicate_block_name' - | 'reserved_block_name' - | 'duplicate_trigger' - | 'duplicate_single_instance_block' +export type SkippedItemType = (typeof WORKFLOW_SKIPPED_ITEM_TYPES)[number] /** * Represents an item that was skipped during operation application diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts rename to apps/sim/lib/workflows/editing/validation.test.ts index abc89f9cf4d..f3255a20200 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -256,7 +256,7 @@ vi.mock('@/tools/utils', () => ({ getTool: mockGetTool, })) -vi.mock('@/lib/copilot/validation/selector-validator', () => ({ +vi.mock('@/lib/workflows/editing/selector-validator', () => ({ validateSelectorIds: mockValidateSelectorIds, })) diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/workflows/editing/validation.ts similarity index 99% rename from apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts rename to apps/sim/lib/workflows/editing/validation.ts index 2780a902496..c964e21bf77 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -1,12 +1,12 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { omit } from '@sim/utils/object' -import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator' import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' +import { validateSelectorIds } from '@/lib/workflows/editing/selector-validator' import { getSkillById } from '@/lib/workflows/skills/operations' import { buildCanonicalIndex, diff --git a/apps/sim/lib/workflows/persistence/duplicate.ts b/apps/sim/lib/workflows/persistence/duplicate.ts index 31a7c342e56..d1ed273d697 100644 --- a/apps/sim/lib/workflows/persistence/duplicate.ts +++ b/apps/sim/lib/workflows/persistence/duplicate.ts @@ -64,6 +64,9 @@ interface DuplicateWorkflowResult { blocksCount: number edgesCount: number subflowsCount: number + /** Stamped by this function, so a caller can present the copy without re-reading it. */ + createdAt: Date + updatedAt: Date } async function assertTargetFolderMutable( @@ -509,6 +512,8 @@ export async function duplicateWorkflow( blocksCount: sourceBlocks.length, edgesCount: sourceEdges.length, subflowsCount: sourceSubflows.length, + createdAt: now, + updatedAt: now, } } diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts new file mode 100644 index 00000000000..d61c17dada8 --- /dev/null +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.ts @@ -0,0 +1,137 @@ +import { db } from '@sim/db' +import { workflow } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' +import { + type PreparedWorkflowState, + prepareWorkflowStateForPersistence, +} from '@/lib/workflows/persistence/prepare-state' +import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' + +const logger = createLogger('WorkflowStateReplacement') + +/** A normalized-table write that could not be committed. */ +export class WorkflowStatePersistenceError extends Error { + constructor(readonly detail: string) { + super('Failed to save workflow state') + this.name = 'WorkflowStatePersistenceError' + } +} + +export interface ReplaceWorkflowNormalizedStateInput { + workflowId: string + /** Canonical workspace the workflow belongs to; custom-tool extraction is skipped without one. */ + workspaceId: string | null + /** Owner recorded on any custom tool this graph defines. */ + attributedUserId: string + state: { + blocks: Record + edges: WorkflowState['edges'] + variables?: Record + lastSaved?: number + isDeployed?: boolean + deployedAt?: Date | null + } + requestId?: string +} + +export interface ReplaceWorkflowNormalizedStateResult { + /** Non-fatal notes about blocks and edges the preparation step rewrote or dropped. */ + warnings: string[] + /** Exactly what was written, after preparation. */ + state: PreparedWorkflowState +} + +/** + * The single door to replacing a workflow's draft graph. + * + * Owns preparation, the row-locked replace transaction, the `lastSynced` + * stamp, the optional variables write, and best-effort custom-tool extraction. + * It owns nothing else: authorization, the mutability check, semantic audit, + * and the realtime notification belong to the application use case above it, so + * a caller cannot acquire one of those by choosing a different entry point. + * + * Takes canonical identifiers only — never a principal or a credential — and + * throws rather than returning a status union, because statuses are a surface's + * business. + * + * `extractAndPersistCustomTools` runs **after** the transaction commits and is + * deliberately best-effort: a failure there leaves the graph written and the + * workspace's custom tools stale, which is the pre-existing behavior of every + * caller and is preserved on purpose. + */ +export async function replaceWorkflowNormalizedState( + input: ReplaceWorkflowNormalizedStateInput +): Promise { + const { workflowId, workspaceId, attributedUserId, state, requestId } = input + const logPrefix = requestId ? `[${requestId}] ` : '' + + const { state: preparedState, warnings } = prepareWorkflowStateForPersistence({ + blocks: state.blocks, + edges: state.edges, + }) + + const workflowState = { + ...preparedState, + lastSaved: state.lastSaved || Date.now(), + isDeployed: state.isDeployed || false, + deployedAt: state.deployedAt, + } as WorkflowState + + const saveResult = await db.transaction(async (tx) => { + await tx + .select({ id: workflow.id }) + .from(workflow) + .where(eq(workflow.id, workflowId)) + .limit(1) + .for('update') + + const result = await saveWorkflowToNormalizedTables(workflowId, workflowState, tx) + if (!result.success) return result + + const updateData: Partial = { + lastSynced: new Date(), + updatedAt: new Date(), + } + if (state.variables !== undefined) { + updateData.variables = state.variables + } + + await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) + + return result + }) + + if (!saveResult.success) { + logger.error(`${logPrefix}Failed to save workflow ${workflowId} state`, { + error: saveResult.error, + }) + throw new WorkflowStatePersistenceError(saveResult.error ?? 'Unknown persistence failure') + } + + if (workspaceId) { + try { + const { saved, errors } = await extractAndPersistCustomTools( + workflowState, + workspaceId, + attributedUserId + ) + if (saved > 0) { + logger.info(`${logPrefix}Persisted ${saved} custom tool(s) to database`, { workflowId }) + } + if (errors.length > 0) { + logger.warn(`${logPrefix}Some custom tools failed to persist`, { errors, workflowId }) + } + } catch (error) { + logger.error(`${logPrefix}Failed to persist custom tools`, { error, workflowId }) + } + } else { + logger.warn(`${logPrefix}Workflow has no workspaceId, skipping custom tools persistence`, { + workflowId, + }) + } + + return { warnings, state: preparedState } +} diff --git a/apps/sim/lib/workflows/persistence/save-normalized-state.ts b/apps/sim/lib/workflows/persistence/save-normalized-state.ts index 6ecfb9cb5c0..4d921ee793f 100644 --- a/apps/sim/lib/workflows/persistence/save-normalized-state.ts +++ b/apps/sim/lib/workflows/persistence/save-normalized-state.ts @@ -1,5 +1,3 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { assertWorkflowMutable, @@ -7,16 +5,16 @@ import { WorkflowLockedError, type WorkflowWorkspaceAuthorizationResult, } from '@sim/platform-authz/workflow' -import { eq } from 'drizzle-orm' import type { z } from 'zod' import { type WorkflowStateContractOutput, workflowStateSchema, } from '@/lib/api/contracts/workflows' import { notifyWorkflowUpdated } from '@/lib/realtime/notify' -import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' -import { prepareWorkflowStateForPersistence } from '@/lib/workflows/persistence/prepare-state' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + replaceWorkflowNormalizedState, + WorkflowStatePersistenceError, +} from '@/lib/workflows/persistence/replace-normalized-state' import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowStatePersistence') @@ -36,14 +34,17 @@ export function parseWorkflowStateForPersistence( } /** - * Writes a complete workflow state to the normalized tables: write authorization, - * the lock check, block/edge preparation, the row-locked save transaction, - * `lastSynced`/variables, custom-tool extraction, and the socket notification. - * Every surface that replaces a workflow's state calls this, so no step can be - * skipped by going through a different door. + * The legacy session-authenticated door to a graph replace, kept for the + * internal editor route. + * + * It authorizes by bare `userId`, checks mutability, delegates the write to + * {@link replaceWorkflowNormalizedState} — the one persistence primitive every + * surface shares — and notifies the realtime server. Every refusal, the lock + * included, comes back as a failure result so callers need one branch. * - * Every refusal, the lock included, comes back as a failure result — callers need - * one branch. + * New surfaces do **not** call this: a `userId` cannot express a workspace-key + * or delegated principal. They go through `replaceWorkflowState`, which + * authorizes as an application operation and records semantic audit. * * `authorization` lets a caller that already resolved the same decision hand it in * rather than pay for it twice; it must be the `write` decision for this workflow @@ -88,89 +89,36 @@ export async function saveWorkflowNormalizedState(params: { throw error } - const { state: preparedState, warnings: preparationWarnings } = - prepareWorkflowStateForPersistence({ - blocks: state.blocks as Record, - edges: state.edges as WorkflowState['edges'], - }) - - const workflowState = { - ...preparedState, - lastSaved: state.lastSaved || Date.now(), - isDeployed: state.isDeployed || false, - deployedAt: state.deployedAt, - } - - const saveResult = await db.transaction(async (tx) => { - await tx - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - .for('update') - - const result = await saveWorkflowToNormalizedTables( - workflowId, - workflowState as WorkflowState, - tx - ) - - if (!result.success) return result - - const updateData: { - lastSynced: Date - updatedAt: Date - variables?: typeof state.variables - } = { - lastSynced: new Date(), - updatedAt: new Date(), - } - - if (state.variables !== undefined) { - updateData.variables = state.variables - } - - await tx.update(workflow).set(updateData).where(eq(workflow.id, workflowId)) - - return result - }) - - if (!saveResult.success) { - logger.error(`[${requestId}] Failed to save workflow ${workflowId} state:`, saveResult.error) - return { - success: false, - status: 500, - error: 'Failed to save workflow state', - details: saveResult.error, - } - } - + let warnings: string[] try { - const workspaceId = workflowData.workspaceId - if (workspaceId) { - const { saved, errors } = await extractAndPersistCustomTools( - workflowState, - workspaceId, - userId - ) - - if (saved > 0) { - logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, { workflowId }) - } - - if (errors.length > 0) { - logger.warn(`[${requestId}] Some custom tools failed to persist`, { errors, workflowId }) + const saved = await replaceWorkflowNormalizedState({ + requestId, + workflowId, + workspaceId: workflowData.workspaceId ?? null, + attributedUserId: userId, + state: { + blocks: state.blocks as Record, + edges: state.edges as WorkflowState['edges'], + variables: state.variables, + lastSaved: state.lastSaved, + isDeployed: state.isDeployed, + deployedAt: state.deployedAt, + }, + }) + warnings = saved.warnings + } catch (error) { + if (error instanceof WorkflowStatePersistenceError) { + return { + success: false, + status: 500, + error: 'Failed to save workflow state', + details: error.detail, } - } else { - logger.warn(`[${requestId}] Workflow has no workspaceId, skipping custom tools persistence`, { - workflowId, - }) } - } catch (error) { - logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId }) + throw error } await notifyWorkflowUpdated(workflowId) - return { success: true, warnings: preparationWarnings } + return { success: true, warnings } } diff --git a/apps/sim/lib/workflows/queries.ts b/apps/sim/lib/workflows/queries.ts index 8e7fa6f0663..f989b3e4d49 100644 --- a/apps/sim/lib/workflows/queries.ts +++ b/apps/sim/lib/workflows/queries.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { workflow } from '@sim/db/schema' -import { and, asc, eq, inArray, isNull, type SQL, sql } from 'drizzle-orm' +import { and, asc, eq, inArray, isNotNull, isNull, type SQL, sql } from 'drizzle-orm' import type { WorkflowListItem } from '@/lib/api/contracts/workflows' import { type CursorKey, @@ -65,6 +65,8 @@ const WORKFLOW_SORTS = { export interface ListWorkspaceWorkflowsInput { workspaceId: string folderId?: string | null + /** `active` (default) or `archived`; `all` is deliberately unavailable here. */ + scope?: 'active' | 'archived' deployedOnly: boolean search?: string sortBy: WorkflowSortBy @@ -104,7 +106,7 @@ export async function listWorkspaceWorkflows(input: ListWorkspaceWorkflowsInput) .where( and( eq(workflow.workspaceId, input.workspaceId), - isNull(workflow.archivedAt), + input.scope === 'archived' ? isNotNull(workflow.archivedAt) : isNull(workflow.archivedAt), folderCondition, input.deployedOnly ? eq(workflow.isDeployed, true) : undefined, searchFilter(workflow.name, input.search), diff --git a/bun.lock b/bun.lock index a3c25e1b56a..3838b780be6 100644 --- a/bun.lock +++ b/bun.lock @@ -1839,8 +1839,6 @@ "@sim/security": ["@sim/security@workspace:packages/security"], - "sim-setup": ["sim-setup@workspace:packages/sim-setup"], - "@sim/terminal-protocol": ["@sim/terminal-protocol@workspace:packages/terminal-protocol"], "@sim/testing": ["@sim/testing@workspace:packages/testing"], @@ -4239,6 +4237,8 @@ "sim": ["sim@workspace:packages/sim-cli"], + "sim-setup": ["sim-setup@workspace:packages/sim-setup"], + "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], "simstudio": ["simstudio@workspace:packages/cli"], diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 2cea676d150..4cd5631e3ac 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: 1130, - zodRoutes: 1130, + totalRoutes: 1136, + zodRoutes: 1136, nonZodRoutes: 0, } as const From e5c4616fc0eddc3cb072519d8a2255374ccd683d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 11:58:35 -0700 Subject: [PATCH 03/55] test(workflows): cover the graph-write primitive, audit source, and the v2 authoring surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the two-doors fix (preparation runs, the row is locked, custom-tool extraction is post-commit and best-effort) and the false-audit fix (a session principal writes source: 'session', a delegated one writes its service). Both were verified to fail with the fix reverted. Add the application matrix for replaceWorkflowState, applyWorkflowOperations, readWorkflowGraph, and restoreWorkflow — role floor, principal-kind rejection before canonical load, asserted-scope concealment, lock, validation, atomic conflict, plan gate, and audit-then-notify ordering — plus route tests for every new endpoint. --- .../v2/workflows/[id]/duplicate/route.test.ts | 134 ++++++ .../workflows/[id]/operations/route.test.ts | 207 +++++++++ .../v2/workflows/[id]/restore/route.test.ts | 123 +++++ .../app/api/v2/workflows/[id]/route.test.ts | 4 +- .../api/v2/workflows/[id]/state/route.test.ts | 223 +++++++++ .../v2/workflows/[id]/variables/route.test.ts | 126 ++++++ .../app/api/v2/workflows/move/route.test.ts | 125 ++++++ apps/sim/app/api/v2/workflows/route.test.ts | 19 +- .../v2/__tests__/workflow-graph.test.ts | 106 +++++ .../lib/core/application/audit-source.test.ts | 33 ++ .../apply-workflow-operations.test.ts | 424 ++++++++++++++++++ .../application/move-workflows-bulk.test.ts | 25 +- .../application/read-workflow-graph.test.ts | 119 +++++ .../replace-workflow-state.test.ts | 232 ++++++++++ .../application/restore-workflow.test.ts | 140 ++++++ .../update-workflow-content.test.ts | 27 +- .../replace-normalized-state.test.ts | 146 ++++++ 17 files changed, 2207 insertions(+), 6 deletions(-) create mode 100644 apps/sim/app/api/v2/workflows/[id]/duplicate/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/operations/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/restore/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/state/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/[id]/variables/route.test.ts create mode 100644 apps/sim/app/api/v2/workflows/move/route.test.ts create mode 100644 apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts create mode 100644 apps/sim/lib/core/application/audit-source.test.ts create mode 100644 apps/sim/lib/workflows/application/apply-workflow-operations.test.ts create mode 100644 apps/sim/lib/workflows/application/read-workflow-graph.test.ts create mode 100644 apps/sim/lib/workflows/application/replace-workflow-state.test.ts create mode 100644 apps/sim/lib/workflows/application/restore-workflow.test.ts create mode 100644 apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts diff --git a/apps/sim/app/api/v2/workflows/[id]/duplicate/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/duplicate/route.test.ts new file mode 100644 index 00000000000..5a1e7069083 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/duplicate/route.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ duplicateWorkflow: vi.fn() })) + +vi.mock('@/lib/workflows/application/duplicate-workflow', () => ({ + duplicateWorkflow: { + operation: { id: 'workflows.duplicate' }, + execute: mocks.duplicateWorkflow, + }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { POST } from '@/app/api/v2/workflows/[id]/duplicate/route' + +const WORKFLOW_ID = 'workflow-1' +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', keyId: 'ws-key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:ws-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const routeContext = { params: Promise.resolve({ id: WORKFLOW_ID }) } + +function request(body: unknown) { + return new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/duplicate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/v2/workflows/[id]/duplicate', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.duplicateWorkflow.mockResolvedValue({ + id: 'workflow-2', + name: 'Daily digest (copy)', + description: null, + workspaceId: 'workspace-1', + folderId: null, + folderPath: '/Operations', + sortOrder: 0, + locked: false, + blocksCount: 3, + edgesCount: 2, + subflowsCount: 0, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + }) + }) + + it('authenticates before parsing the body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await POST(request({ nonsense: true }), routeContext) + + expect(response.status).toBe(401) + expect(mocks.duplicateWorkflow).not.toHaveBeenCalled() + }) + + it('creates the copy with the workflow summary contract', async () => { + const response = await POST(request({ folderPath: '/Operations' }), routeContext) + + expect(response.status).toBe(201) + expect(await response.json()).toEqual({ + data: { + id: 'workflow-2', + name: 'Daily digest (copy)', + description: null, + folderPath: '/Operations', + workspaceId: 'workspace-1', + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + }, + }) + expect(mocks.duplicateWorkflow).toHaveBeenCalledWith({ + principal: auth.principal, + input: { sourceWorkflowId: WORKFLOW_ID, name: undefined, folderPath: '/Operations' }, + request: expect.anything(), + }) + }) + + it('accepts an empty body and lets the use case default the name', async () => { + const response = await POST(request({}), routeContext) + + expect(response.status).toBe(201) + expect(mocks.duplicateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + input: { sourceWorkflowId: WORKFLOW_ID, name: undefined, folderPath: undefined }, + }) + ) + }) + + it('rejects an unknown body member', async () => { + const response = await POST(request({ folderId: 'folder-1' }), routeContext) + + expect(response.status).toBe(400) + expect(mocks.duplicateWorkflow).not.toHaveBeenCalled() + }) + + it('conceals a cross-tenant duplicate as not found', async () => { + mocks.duplicateWorkflow.mockRejectedValue(new NoWorkspaceAccessError('workspace-2')) + + const response = await POST(request({}), routeContext) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/operations/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/operations/route.test.ts new file mode 100644 index 00000000000..67f4e2d4832 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/operations/route.test.ts @@ -0,0 +1,207 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + applyWorkflowOperations: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/apply-workflow-operations', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + class WorkflowOperationsNotAppliedError extends OrchestrationError { + constructor(readonly skipped: unknown[]) { + super('conflict', `${skipped.length} operation(s) could not be applied`) + this.name = 'WorkflowOperationsNotAppliedError' + } + } + return { + WorkflowOperationsNotAppliedError, + applyWorkflowOperations: { + operation: { id: 'workflows.operations.apply' }, + execute: mocks.applyWorkflowOperations, + }, + } +}) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/apply-workflow-operations' +import { POST } from '@/app/api/v2/workflows/[id]/operations/route' + +const WORKFLOW_ID = 'workflow-1' +const SKIPPED = { + type: 'duplicate_block_name', + operationType: 'add', + blockId: 'block-2', + reason: 'Name taken', +} + +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const routeContext = { params: Promise.resolve({ id: WORKFLOW_ID }) } + +function request(body: unknown) { + return new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/operations`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +const ADD = { + operation_type: 'add', + block_id: 'block-2', + params: { type: 'agent', name: 'Triage' }, +} + +describe('/api/v2/workflows/[id]/operations', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.applyWorkflowOperations.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workflowName: 'Daily digest', + workspaceId: 'workspace-1', + graph: { blocks: {}, edges: [], loops: {}, parallels: {} }, + operationCount: 1, + applied: 1, + skipped: [], + deferred: [], + inputValidationErrors: [], + lint: { unresolvedReferences: [], notes: [] }, + warnings: [], + needsRedeployment: true, + }) + }) + + it('authenticates before parsing the body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await POST(request({ nonsense: true }), routeContext) + + expect(response.status).toBe(401) + expect(mocks.applyWorkflowOperations).not.toHaveBeenCalled() + }) + + it('applies a batch and returns the exact result contract', async () => { + const response = await POST(request({ operations: [ADD] }), routeContext) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: WORKFLOW_ID, + applied: 1, + skipped: [], + deferred: [], + inputValidationErrors: [], + lint: { unresolvedReferences: [], notes: [] }, + warnings: [], + needsRedeployment: true, + }, + }) + expect(mocks.applyWorkflowOperations).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + workflowId: WORKFLOW_ID, + operations: [ADD], + atomic: false, + layout: 'targeted', + }), + }) + ) + }) + + it('maps the setBlockEnabled flag onto the use case input', async () => { + await POST( + request({ + operations: [ADD], + setBlockEnabled: [{ block_id: 'block-1', enabled: false }], + }), + routeContext + ) + + expect(mocks.applyWorkflowOperations).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + blockEnabledChanges: [{ blockId: 'block-1', enabled: false }], + }), + }) + ) + }) + + it('answers a refused atomic batch with 409 and the declined operations', async () => { + mocks.applyWorkflowOperations.mockRejectedValue( + new WorkflowOperationsNotAppliedError([SKIPPED] as never) + ) + + const response = await POST(request({ operations: [ADD], atomic: true }), routeContext) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: { + code: 'CONFLICT', + message: '1 operation(s) could not be applied', + details: { code: 'OPERATIONS_NOT_APPLIED', skipped: [SKIPPED] }, + }, + }) + }) + + it('conceals a cross-tenant write as not found', async () => { + mocks.applyWorkflowOperations.mockRejectedValue(new NoWorkspaceAccessError('workspace-2')) + + const response = await POST(request({ operations: [ADD] }), routeContext) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects an empty batch', async () => { + const response = await POST(request({ operations: [] }), routeContext) + + expect(response.status).toBe(400) + expect(mocks.applyWorkflowOperations).not.toHaveBeenCalled() + }) + + it('rejects an add operation with no block type or name', async () => { + const response = await POST( + request({ operations: [{ operation_type: 'add', block_id: 'block-2', params: {} }] }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.applyWorkflowOperations).not.toHaveBeenCalled() + }) + + it('rejects params on a delete operation', async () => { + const response = await POST( + request({ + operations: [{ operation_type: 'delete', block_id: 'block-2', params: { type: 'agent' } }], + }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.applyWorkflowOperations).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/restore/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/restore/route.test.ts new file mode 100644 index 00000000000..578e3e949a4 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/restore/route.test.ts @@ -0,0 +1,123 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ restoreWorkflow: vi.fn() })) + +vi.mock('@/lib/workflows/application/restore-workflow', () => ({ + restoreWorkflow: { operation: { id: 'workflows.restore' }, execute: mocks.restoreWorkflow }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/v2/workflows/[id]/restore/route' + +const WORKFLOW_ID = 'workflow-1' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const routeContext = { params: Promise.resolve({ id: WORKFLOW_ID }) } +const url = `http://localhost/api/v2/workflows/${WORKFLOW_ID}/restore` + +describe('/api/v2/workflows/[id]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.restoreWorkflow.mockResolvedValue({ + workflow: { + id: WORKFLOW_ID, + name: 'Daily digest', + description: null, + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-02T00:00:00.000Z'), + }, + workspaceId: 'workspace-1', + folderPath: '/', + }) + }) + + it('authenticates before running the use case', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await POST(new NextRequest(url, { method: 'POST' }), routeContext) + + expect(response.status).toBe(401) + expect(mocks.restoreWorkflow).not.toHaveBeenCalled() + }) + + it('returns the restored workflow summary', async () => { + const response = await POST(new NextRequest(url, { method: 'POST' }), routeContext) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: WORKFLOW_ID, + name: 'Daily digest', + description: null, + folderPath: '/', + workspaceId: 'workspace-1', + isDeployed: false, + deployedAt: null, + runCount: 0, + lastRunAt: null, + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-02T00:00:00.000Z', + }, + }) + }) + + it('answers a workflow that is not archived with 409', async () => { + mocks.restoreWorkflow.mockRejectedValue( + new OrchestrationError('conflict', 'Workflow is not archived') + ) + + const response = await POST(new NextRequest(url, { method: 'POST' }), routeContext) + + expect(response.status).toBe(409) + expect((await response.json()).error).toEqual({ + code: 'CONFLICT', + message: 'Workflow is not archived', + }) + }) + + it('conceals a cross-tenant restore as not found', async () => { + mocks.restoreWorkflow.mockRejectedValue(new NoWorkspaceAccessError('workspace-2')) + + const response = await POST(new NextRequest(url, { method: 'POST' }), routeContext) + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') + }) + + it('rejects an undeclared query param', async () => { + const response = await POST(new NextRequest(`${url}?force=1`, { method: 'POST' }), routeContext) + + expect(response.status).toBe(400) + expect(mocks.restoreWorkflow).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/route.test.ts index 72003b5574f..821fa7122e3 100644 --- a/apps/sim/app/api/v2/workflows/[id]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/route.test.ts @@ -157,7 +157,9 @@ describe('/api/v2/workflows/[id]', () => { const response = await DELETE(request, routeContext) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: { id: WORKFLOW_ID, deleted: true } }) + expect(await response.json()).toEqual({ + data: { id: WORKFLOW_ID, deleted: true, archived: true }, + }) expect(mocks.deleteWorkflow).toHaveBeenCalledWith({ principal: auth.principal, input: { workflowId: WORKFLOW_ID }, diff --git a/apps/sim/app/api/v2/workflows/[id]/state/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/state/route.test.ts new file mode 100644 index 00000000000..c9a98bc9bbf --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/state/route.test.ts @@ -0,0 +1,223 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + readWorkflowGraph: vi.fn(), + replaceWorkflowState: vi.fn(), +})) + +vi.mock('@/lib/workflows/application/read-workflow-graph', () => ({ + readWorkflowGraph: { operation: { id: 'workflows.read' }, execute: mocks.readWorkflowGraph }, +})) +vi.mock('@/lib/workflows/application/replace-workflow-state', () => ({ + replaceWorkflowState: { + operation: { id: 'workflows.state.replace' }, + execute: mocks.replaceWorkflowState, + }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { GET, PUT } from '@/app/api/v2/workflows/[id]/state/route' + +const WORKFLOW_ID = 'workflow-1' +const BLOCK = { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { 'sub-1': { id: 'sub-1', type: 'short-input', value: 'hello' } }, + outputs: {}, + enabled: true, +} +const GRAPH = { + blocks: { 'block-1': BLOCK }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, +} + +const personalAuth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:personal-key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} +const workspaceAuth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', keyId: 'ws-key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:ws-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const routeContext = { params: Promise.resolve({ id: WORKFLOW_ID }) } + +function putRequest(body: unknown) { + return new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/v2/workflows/[id]/state', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(personalAuth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.readWorkflowGraph.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workspaceId: 'workspace-1', + ...GRAPH, + }) + mocks.replaceWorkflowState.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workflowName: 'Daily digest', + workspaceId: 'workspace-1', + blocksCount: 1, + edgesCount: 0, + warnings: ['Dropped edge "edge-9": target block does not exist'], + needsRedeployment: true, + }) + }) + + it('returns the graph in the v2 envelope with a private, no-store cache directive', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state`), + routeContext + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(await response.json()).toEqual({ data: GRAPH }) + }) + + it('answers a HEAD through the GET without a body', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state`, { + method: 'HEAD', + }), + routeContext + ) + + expect(response.status).toBe(200) + expect(mocks.readWorkflowGraph).toHaveBeenCalledOnce() + }) + + it('rejects an undeclared query param', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state?bogus=1`), + routeContext + ) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.readWorkflowGraph).not.toHaveBeenCalled() + }) + + it('conceals a cross-tenant read as not found, never forbidden', async () => { + mocks.readWorkflowGraph.mockRejectedValue(new NoWorkspaceAccessError('workspace-2')) + + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/state`), + routeContext + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ + error: { code: 'NOT_FOUND', message: 'Workflow not found' }, + }) + }) + + it('authenticates before parsing the write body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await PUT(putRequest({ nonsense: true }), routeContext) + + expect(response.status).toBe(401) + expect(v2RouteMocks.operationRate).not.toHaveBeenCalled() + expect(mocks.replaceWorkflowState).not.toHaveBeenCalled() + }) + + it('replaces the graph and returns the preparation warnings', async () => { + const response = await PUT( + putRequest({ blocks: { 'block-1': BLOCK }, edges: [] }), + routeContext + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: WORKFLOW_ID, + warnings: ['Dropped edge "edge-9": target block does not exist'], + needsRedeployment: true, + }, + }) + expect(mocks.replaceWorkflowState).toHaveBeenCalledWith({ + principal: personalAuth.principal, + input: { + workflowId: WORKFLOW_ID, + blocks: { 'block-1': BLOCK }, + edges: [], + variables: undefined, + }, + request: expect.anything(), + }) + }) + + it('accepts a workspace API key, which the operation policy allows', async () => { + v2RouteMocks.authenticate.mockResolvedValue(workspaceAuth) + + const response = await PUT( + putRequest({ blocks: { 'block-1': BLOCK }, edges: [] }), + routeContext + ) + + expect(response.status).toBe(200) + expect(mocks.replaceWorkflowState).toHaveBeenCalledWith( + expect.objectContaining({ principal: workspaceAuth.principal }) + ) + }) + + it('accepts a graph read straight back from the GET', async () => { + const response = await PUT(putRequest(GRAPH), routeContext) + + expect(response.status).toBe(200) + }) + + it('rejects an unknown top-level body member', async () => { + const response = await PUT(putRequest({ blocks: {}, edges: [], lastSaved: 1 }), routeContext) + + expect(response.status).toBe(400) + expect(mocks.replaceWorkflowState).not.toHaveBeenCalled() + }) + + it('rejects a block missing the fields the tables require', async () => { + const response = await PUT( + putRequest({ blocks: { 'block-1': { id: 'block-1', type: 'starter' } }, edges: [] }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.replaceWorkflowState).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/[id]/variables/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/variables/route.test.ts new file mode 100644 index 00000000000..07b828937ac --- /dev/null +++ b/apps/sim/app/api/v2/workflows/[id]/variables/route.test.ts @@ -0,0 +1,126 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ applyWorkflowVariableOperations: vi.fn() })) + +vi.mock('@/lib/workflows/application/update-workflow-content', () => ({ + applyWorkflowVariableOperations: { + operation: { id: 'workflows.variables.apply_operations' }, + execute: mocks.applyWorkflowVariableOperations, + }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { PATCH } from '@/app/api/v2/workflows/[id]/variables/route' + +const WORKFLOW_ID = 'workflow-1' +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: 'workspace-1', keyId: 'ws-key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:ws-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const routeContext = { params: Promise.resolve({ id: WORKFLOW_ID }) } + +function request(body: unknown) { + return new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/variables`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/v2/workflows/[id]/variables', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.applyWorkflowVariableOperations.mockResolvedValue({ updated: 3, changed: true }) + }) + + it('authenticates before parsing the body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await PATCH(request({ nonsense: true }), routeContext) + + expect(response.status).toBe(401) + expect(mocks.applyWorkflowVariableOperations).not.toHaveBeenCalled() + }) + + it('applies a batch under a workspace API key, which the widened policy allows', async () => { + const response = await PATCH( + request({ operations: [{ operation: 'add', name: 'region', type: 'string', value: 'eu' }] }), + routeContext + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { id: WORKFLOW_ID, variableCount: 3, changed: true }, + }) + expect(mocks.applyWorkflowVariableOperations).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workflowId: WORKFLOW_ID, + operations: [{ operation: 'add', name: 'region', type: 'string', value: 'eu' }], + }, + request: expect.anything(), + }) + }) + + it('does not forward a value or type on a delete operation', async () => { + await PATCH(request({ operations: [{ operation: 'delete', name: 'region' }] }), routeContext) + + expect(mocks.applyWorkflowVariableOperations).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + operations: [{ operation: 'delete', name: 'region' }], + }), + }) + ) + }) + + it('reports an authoritative no-op without pretending anything changed', async () => { + mocks.applyWorkflowVariableOperations.mockResolvedValue({ updated: 3, changed: false }) + + const response = await PATCH( + request({ operations: [{ operation: 'delete', name: 'missing' }] }), + routeContext + ) + + expect(response.status).toBe(200) + expect((await response.json()).data.changed).toBe(false) + }) + + it('rejects a value on a delete operation', async () => { + const response = await PATCH( + request({ operations: [{ operation: 'delete', name: 'region', value: 'eu' }] }), + routeContext + ) + + expect(response.status).toBe(400) + expect(mocks.applyWorkflowVariableOperations).not.toHaveBeenCalled() + }) + + it('rejects an empty batch', async () => { + const response = await PATCH(request({ operations: [] }), routeContext) + + expect(response.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/v2/workflows/move/route.test.ts b/apps/sim/app/api/v2/workflows/move/route.test.ts new file mode 100644 index 00000000000..c9ee15951e0 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/move/route.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import { + MockV2ApiKeyUnauthenticatedError, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ moveWorkflowsBulk: vi.fn() })) + +vi.mock('@/lib/workflows/application/move-workflows-bulk', () => ({ + moveWorkflowsBulk: { operation: { id: 'workflows.bulk.move' }, execute: mocks.moveWorkflowsBulk }, +})) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) + +import { POST } from '@/app/api/v2/workflows/move/route' + +const WORKSPACE_ID = 'workspace-1' +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'ws-key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:ws-key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +function request(body: unknown) { + return new NextRequest('http://localhost/api/v2/workflows/move', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/v2/workflows/move', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.moveWorkflowsBulk.mockResolvedValue({ + moved: ['workflow-1'], + failed: ['workflow-2'], + folderId: 'folder-1', + changes: [], + }) + }) + + it('authenticates before parsing the body', async () => { + v2RouteMocks.authenticate.mockRejectedValue(new MockV2ApiKeyUnauthenticatedError('No API key')) + + const response = await POST(request({ nonsense: true })) + + expect(response.status).toBe(401) + expect(mocks.moveWorkflowsBulk).not.toHaveBeenCalled() + }) + + it('exposes both arms of the best-effort result', async () => { + const response = await POST( + request({ + workspaceId: WORKSPACE_ID, + workflowIds: ['workflow-1', 'workflow-2'], + folderPath: '/Operations', + }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { moved: ['workflow-1'], failed: ['workflow-2'], folderPath: '/Operations' }, + }) + expect(mocks.moveWorkflowsBulk).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + workflowIds: ['workflow-1', 'workflow-2'], + folderPath: '/Operations', + }, + request: expect.anything(), + }) + }) + + it('normalizes a folder path with no leading slash', async () => { + await POST( + request({ workspaceId: WORKSPACE_ID, workflowIds: ['workflow-1'], folderPath: 'Operations' }) + ) + + expect(mocks.moveWorkflowsBulk).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ folderPath: '/Operations' }), + }) + ) + }) + + it('rejects a batch above the cap', async () => { + const response = await POST( + request({ + workspaceId: WORKSPACE_ID, + workflowIds: Array.from({ length: 101 }, (_, index) => `workflow-${index}`), + folderPath: '/Operations', + }) + ) + + expect(response.status).toBe(400) + expect(mocks.moveWorkflowsBulk).not.toHaveBeenCalled() + }) + + it('rejects a folderId, which is not part of the public surface', async () => { + const response = await POST( + request({ workspaceId: WORKSPACE_ID, workflowIds: ['workflow-1'], folderId: null }) + ) + + expect(response.status).toBe(400) + expect(mocks.moveWorkflowsBulk).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 0eea5e18ef4..6db1fa42ef1 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -33,6 +33,15 @@ vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) import { GET, POST } from '@/app/api/v2/workflows/route' const WORKSPACE_ID = 'workspace-1' +const SEEDED_START_BLOCK = { + id: 'start-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, +} const WORKFLOW = { id: 'workflow-1', name: 'Daily digest', @@ -86,7 +95,11 @@ describe('/api/v2/workflows', () => { sortBy: 'position', sortOrder: 'asc', }) - mocks.createWorkflow.mockResolvedValue({ workflow: WORKFLOW, folderPath: '/' }) + mocks.createWorkflow.mockResolvedValue({ + workflow: WORKFLOW, + folderPath: '/', + normalizedState: { blocks: { 'start-1': SEEDED_START_BLOCK } }, + }) }) it('authenticates and rate limits before parsing list input', async () => { @@ -208,7 +221,9 @@ describe('/api/v2/workflows', () => { const response = await POST(request) expect(response.status).toBe(201) - expect((await response.json()).data.id).toBe(WORKFLOW.id) + const created = (await response.json()).data + expect(created.id).toBe(WORKFLOW.id) + expect(created.blocks).toEqual([{ id: 'start-1', type: 'starter', name: 'Start' }]) expect(mocks.createWorkflow).toHaveBeenCalledWith({ principal: personalAuth.principal, input: { workspaceId: WORKSPACE_ID, name: WORKFLOW.name }, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts new file mode 100644 index 00000000000..64cfe377712 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + v2ApplyWorkflowOperationsDataSchema, + v2ReplaceWorkflowStateBodySchema, + v2WorkflowGraphSchema, +} from '@/lib/api/contracts/v2/workflows' +import { WORKFLOW_SKIPPED_ITEM_TYPES } from '@/lib/workflows/editing/types' + +const STORED_GRAPH = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: { 'sub-1': { id: 'sub-1', type: 'oauth-input', value: 'credential-1' } }, + outputs: { result: { type: 'string' } }, + enabled: true, + horizontalHandles: true, + height: 0, + data: { parentId: 'loop-1', extent: 'parent' }, + /** A stored key this surface does not publish. */ + layout: { measured: true }, + }, + }, + edges: [ + { + id: 'edge-1', + source: 'block-1', + target: 'block-2', + sourceHandle: null, + targetHandle: null, + /** Reactflow rendering members the stored row carries. */ + animated: true, + style: { stroke: '#000' }, + }, + ], + loops: { + 'loop-1': { id: 'loop-1', nodes: ['block-1'], iterations: 3, loopType: 'for', enabled: true }, + }, + parallels: {}, + variables: { + 'var-1': { + id: 'var-1', + name: 'region', + type: 'string', + value: 'eu', + /** Server-stamped for the client's global variables store; not part of this surface. */ + workflowId: 'workflow-1', + }, + }, +} + +describe('v2WorkflowGraphSchema', () => { + /** + * A v2 response schema is `.parse`d on the way out, so a stored member the + * surface has not published must be stripped rather than rejected — a throw + * here would be a 500 on a plain read. + */ + it('canonicalizes a stored graph instead of rejecting its unpublished members', () => { + const parsed = v2WorkflowGraphSchema.parse(STORED_GRAPH) + + expect(parsed.blocks['block-1']).not.toHaveProperty('layout') + expect(parsed.edges[0]).not.toHaveProperty('animated') + expect(parsed.variables['var-1']).not.toHaveProperty('workflowId') + expect(parsed.blocks['block-1'].subBlocks['sub-1'].value).toBe('credential-1') + expect(parsed.loops['loop-1'].iterations).toBe(3) + }) + + /** The round trip has to close: what the read emits is what the write accepts. */ + it('accepts its own output as a replacement body', () => { + const parsed = v2WorkflowGraphSchema.parse(STORED_GRAPH) + + expect(v2ReplaceWorkflowStateBodySchema.safeParse(parsed).success).toBe(true) + }) + + it('rejects an unknown top-level member on the write body', () => { + const parsed = v2WorkflowGraphSchema.parse(STORED_GRAPH) + + expect( + v2ReplaceWorkflowStateBodySchema.safeParse({ ...parsed, lastSaved: Date.now() }).success + ).toBe(false) + }) +}) + +describe('v2ApplyWorkflowOperationsDataSchema', () => { + /** The published skip vocabulary is the engine's, so a new reason cannot ship undocumented. */ + it('publishes every reason the engine can decline an operation for', () => { + for (const type of WORKFLOW_SKIPPED_ITEM_TYPES) { + const result = v2ApplyWorkflowOperationsDataSchema.safeParse({ + id: 'workflow-1', + warnings: [], + needsRedeployment: false, + applied: 0, + skipped: [{ type, operationType: 'add', blockId: 'block-1', reason: 'because' }], + deferred: [], + inputValidationErrors: [], + lint: { unresolvedReferences: [], notes: [] }, + }) + expect(result.success, `skip type ${type} is not published`).toBe(true) + } + }) +}) diff --git a/apps/sim/lib/core/application/audit-source.test.ts b/apps/sim/lib/core/application/audit-source.test.ts new file mode 100644 index 00000000000..d99da04f186 --- /dev/null +++ b/apps/sim/lib/core/application/audit-source.test.ts @@ -0,0 +1,33 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { principalAuditSource } from '@/lib/core/application/audit-source' + +describe('principalAuditSource', () => { + it('names the delegated service rather than the kind', () => { + expect( + principalAuditSource({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date(), + expiresAt: new Date(), + }) + ).toBe('copilot') + }) + + it.each([ + [{ kind: 'session', userId: 'user-1', sessionId: 'session-1' }, 'session'], + [{ kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, 'personal_api_key'], + [ + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + 'workspace_api_key', + ], + ] as const)('names the credential class for %#', (principal, expected) => { + expect(principalAuditSource(principal)).toBe(expected) + }) +}) diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts new file mode 100644 index 00000000000..928c21c16c1 --- /dev/null +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -0,0 +1,424 @@ +/** + * @vitest-environment node + */ +import { WorkflowLockedError } from '@sim/platform-authz/workflow' +import { workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + notify: vi.fn(), + replace: vi.fn(), + validate: vi.fn(), + needsRedeployment: vi.fn(), + applyOperations: vi.fn(), + loadNormalized: vi.fn(), + normalizeState: vi.fn(), + sandboxAccess: vi.fn(), + blockVisibility: vi.fn(), + permissionConfig: vi.fn(), + preValidate: vi.fn(), + collectReferences: vi.fn(), + collectToolReferences: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_UPDATED: 'workflow.updated' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/workflows/persistence/replace-normalized-state', () => ({ + replaceWorkflowNormalizedState: mocks.replace, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + loadWorkflowFromNormalizedTables: mocks.loadNormalized, +})) +vi.mock('@/lib/workflows/sanitization/validation', () => ({ + validateWorkflowState: mocks.validate, +})) +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: mocks.needsRedeployment, +})) +vi.mock('@/lib/workflows/editing/engine', () => ({ + applyOperationsToWorkflowState: mocks.applyOperations, +})) +vi.mock('@/lib/workflows/editing/validation', () => ({ + collectUnresolvedAgentToolReferences: mocks.collectToolReferences, + collectUnresolvedReferences: mocks.collectReferences, + preValidateCredentialInputs: mocks.preValidate, + UNRESOLVABLE_AT_LINT_NOTE: 'lint note', +})) +vi.mock('@/lib/workflows/editing/lint', () => ({ + collectWorkflowFieldIssues: () => [], + lintEditedWorkflowState: () => ({ sources: [], sinks: [], orphans: [] }), +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceSandboxAccess: mocks.sandboxAccess, +})) +vi.mock('@/lib/core/config/block-visibility', () => ({ getBlockVisibility: mocks.blockVisibility })) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.permissionConfig, +})) +vi.mock('@/blocks/visibility/server-context', () => ({ + withBlockVisibility: (_state: unknown, run: () => unknown) => run(), +})) +vi.mock('@/stores/workflows/workflow/utils', () => ({ + generateLoopBlocks: () => ({}), + generateParallelBlocks: () => ({}), +})) +vi.mock('@/stores/workflows/workflow/validation', () => ({ + normalizeWorkflowState: mocks.normalizeState, +})) +vi.mock('@/lib/workflows/autolayout', () => ({ + applyTargetedLayout: vi.fn(), + getTargetedLayoutImpact: () => ({ + layoutBlockIds: [], + resizedBlockIds: [], + shiftSourceBlockIds: [], + }), + transferBlockHeights: vi.fn(), +})) + +import { ForbiddenOperationError } from '@/lib/core/application' +import { + applyWorkflowOperations, + WorkflowOperationsNotAppliedError, +} from '@/lib/workflows/application/apply-workflow-operations' + +const BLOCK = { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, +} + +const context = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Daily digest', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const sessionPrincipal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const copilotPrincipal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), +} + +const operations = [ + { + operation_type: 'add' as const, + block_id: 'block-2', + params: { type: 'agent', name: 'Triage' }, + }, +] + +function graph(blocks: Record = { 'block-1': BLOCK }) { + return { blocks, edges: [], loops: {}, parallels: {} } +} + +describe('applyWorkflowOperations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mocks.sandboxAccess.mockResolvedValue(true) + mocks.blockVisibility.mockResolvedValue({ revealed: [], disabled: [], previewTagged: [] }) + mocks.permissionConfig.mockResolvedValue(null) + mocks.loadNormalized.mockResolvedValue(graph()) + mocks.normalizeState.mockReturnValue({ state: graph(), warnings: [] }) + mocks.preValidate.mockResolvedValue({ filteredOperations: operations, errors: [] }) + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [], + }) + mocks.collectReferences.mockResolvedValue([]) + mocks.collectToolReferences.mockResolvedValue([]) + mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: [] }) + mocks.replace.mockResolvedValue({ warnings: [], state: graph() }) + mocks.needsRedeployment.mockResolvedValue(true) + }) + + it('writes once, through the shared persistence primitive', async () => { + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(mocks.replace).toHaveBeenCalledTimes(1) + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ workflowId: 'workflow-1', workspaceId: 'workspace-1' }) + ) + expect(result.applied).toBe(1) + expect(result.needsRedeployment).toBe(true) + }) + + it('reports declined operations rather than failing the batch', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [ + { + type: 'duplicate_block_name', + operationType: 'add', + blockId: 'block-2', + reason: 'Name taken', + }, + ], + }) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(result.skipped).toHaveLength(1) + expect(result.applied).toBe(0) + expect(mocks.replace).toHaveBeenCalledTimes(1) + }) + + it('separates self-healing deferrals from genuine failures', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [ + { + type: 'invalid_edge_target', + operationType: 'add', + blockId: 'block-2', + reason: 'Target not created yet', + }, + ], + }) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(result.skipped).toHaveLength(0) + expect(result.deferred).toHaveLength(1) + }) + + it('aborts an atomic batch before the write and carries the declined operations', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [ + { + type: 'block_locked', + operationType: 'edit', + blockId: 'block-1', + reason: 'Block is locked', + }, + ], + }) + + const failure = await applyWorkflowOperations + .execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, atomic: true }, + }) + .catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(WorkflowOperationsNotAppliedError) + expect((failure as WorkflowOperationsNotAppliedError).code).toBe('conflict') + expect((failure as WorkflowOperationsNotAppliedError).skipped).toHaveLength(1) + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + /** + * The legacy tool threw a bare `Error(MAX_PLAN_REQUIRED)`, which on a public + * surface is an unclassified 500. + */ + it('names the plan capability when the workspace cannot use sandboxes', async () => { + mocks.sandboxAccess.mockResolvedValue(false) + + const failure = await applyWorkflowOperations + .execute({ + principal: sessionPrincipal, + input: { + workflowId: 'workflow-1', + operations: [ + { + operation_type: 'edit', + block_id: 'block-1', + params: { inputs: { sandboxId: 'sandbox-1' } }, + }, + ], + }, + }) + .catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(ForbiddenOperationError) + expect((failure as ForbiddenOperationError).detailCode).toBe( + 'WORKSPACE_PLAN_CAPABILITY_REQUIRED' + ) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('honours a caller-supplied base graph only for a delegated principal', async () => { + const baseGraph = graph({ 'block-9': { ...BLOCK, id: 'block-9' } }) + + await applyWorkflowOperations.execute({ + principal: copilotPrincipal, + input: { workflowId: 'workflow-1', operations, baseGraph }, + }) + expect(mocks.loadNormalized).not.toHaveBeenCalled() + expect(mocks.applyOperations).toHaveBeenCalledWith(baseGraph, operations, null) + + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + mocks.sandboxAccess.mockResolvedValue(true) + mocks.blockVisibility.mockResolvedValue({ revealed: [], disabled: [], previewTagged: [] }) + mocks.permissionConfig.mockResolvedValue(null) + mocks.loadNormalized.mockResolvedValue(graph()) + mocks.normalizeState.mockReturnValue({ state: graph(), warnings: [] }) + mocks.preValidate.mockResolvedValue({ filteredOperations: operations, errors: [] }) + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [], + }) + mocks.collectReferences.mockResolvedValue([]) + mocks.collectToolReferences.mockResolvedValue([]) + mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: [] }) + mocks.replace.mockResolvedValue({ warnings: [], state: graph() }) + mocks.needsRedeployment.mockResolvedValue(true) + + await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, baseGraph }, + }) + expect(mocks.loadNormalized).toHaveBeenCalledWith('workflow-1') + expect(mocks.applyOperations).not.toHaveBeenCalledWith(baseGraph, operations, null) + }) + + it('applies the block enablement slice and declines a locked block as a skipped item', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph({ 'block-1': { ...BLOCK, locked: true } }), + validationErrors: [], + skippedItems: [], + }) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { + workflowId: 'workflow-1', + operations, + blockEnabledChanges: [{ blockId: 'block-1', enabled: false }], + }, + }) + + expect(result.skipped).toEqual([ + expect.objectContaining({ type: 'block_locked', operationType: 'set_block_enabled' }), + ]) + }) + + it('projects audit from the authoritative result and notifies after it', async () => { + await applyWorkflowOperations.execute({ + principal: copilotPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.updated', + resourceId: 'workflow-1', + metadata: expect.objectContaining({ + operation: 'workflows.operations.apply', + op: 'apply_operations', + operationCount: 1, + appliedCount: 1, + skippedCount: 0, + source: 'copilot', + }), + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledBefore(mocks.notify) + }) + + it('refuses a locked workflow before loading the graph', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue( + new WorkflowLockedError('Workflow is locked') + ) + + await expect( + applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + ).rejects.toMatchObject({ code: 'locked' }) + + expect(mocks.loadNormalized).not.toHaveBeenCalled() + }) + + it('rejects a principal kind the operation does not accept before canonical loading', async () => { + await expect( + applyWorkflowOperations.execute({ + principal: { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'someone@example.com', + invitationTokenHash: 'hash', + }, + input: { workflowId: 'workflow-1', operations }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) + + it('rejects a graph the engine produced that does not validate, without writing', async () => { + mocks.validate.mockReturnValue({ + valid: false, + errors: ['Dangling edge'], + warnings: [], + }) + + await expect( + applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts index d2a83f8eb61..187a68c1a95 100644 --- a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts @@ -141,14 +141,35 @@ describe('moveWorkflowsBulk', () => { expect(mocks.audit).not.toHaveBeenCalled() }) - it('rejects a non-Copilot principal before canonical workspace loading', async () => { + it('rejects a principal kind the operation does not accept, before canonical workspace loading', async () => { await expect( moveWorkflowsBulk.execute({ - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + principal: { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'someone@example.com', + invitationTokenHash: 'hash', + }, input: { workspaceId: 'workspace-1', workflowIds: ['workflow-1'], folderId: null }, }) ).rejects.toMatchObject({ code: 'forbidden' }) expect(mocks.resolveContext).not.toHaveBeenCalled() }) + + it('refuses folderPath and folderId together', async () => { + await expect( + moveWorkflowsBulk.execute({ + principal, + input: { + workspaceId: 'workspace-1', + workflowIds: ['workflow-1'], + folderId: null, + folderPath: '/Operations', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) }) diff --git a/apps/sim/lib/workflows/application/read-workflow-graph.test.ts b/apps/sim/lib/workflows/application/read-workflow-graph.test.ts new file mode 100644 index 00000000000..81851bb2193 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-workflow-graph.test.ts @@ -0,0 +1,119 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + loadSnapshot: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: {}, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) +vi.mock('@/lib/workflows/queries', () => ({ loadWorkflowReadSnapshot: mocks.loadSnapshot })) + +import { readWorkflowGraph } from '@/lib/workflows/application/read-workflow-graph' + +const context = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Daily digest', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' } +const input = { workflowId: 'workflow-1' } + +const VARIABLES = { + 'var-1': { id: 'var-1', workflowId: 'workflow-1', name: 'region', type: 'string', value: 'eu' }, +} + +describe('readWorkflowGraph', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('read') + mocks.loadSnapshot.mockResolvedValue({ + workflowRecord: { id: 'workflow-1', variables: VARIABLES }, + normalizedData: { blocks: { 'block-1': { id: 'block-1' } }, edges: [] }, + }) + }) + + it('returns the unsanitized draft graph with loop and parallel containers always present', async () => { + await expect(readWorkflowGraph.execute({ principal, input })).resolves.toEqual({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + blocks: { 'block-1': { id: 'block-1' } }, + edges: [], + loops: {}, + parallels: {}, + variables: VARIABLES, + }) + }) + + /** + * The pollability guarantee: auditing this read would force `headSafe: false` + * and make the endpoint unusable for the polling it exists to serve. + */ + it('records no audit event', async () => { + await readWorkflowGraph.execute({ principal, input }) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('is not found when the workflow has no normalized state', async () => { + mocks.loadSnapshot.mockResolvedValue({ + workflowRecord: { id: 'workflow-1', variables: null }, + normalizedData: null, + }) + + await expect(readWorkflowGraph.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + }) + + it('rejects a principal kind the operation does not accept before canonical loading', async () => { + await expect( + readWorkflowGraph.execute({ + principal: { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'someone@example.com', + invitationTokenHash: 'hash', + }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) + + it('answers the authorization phase without loading the graph', async () => { + await readWorkflowGraph.authorize?.({ principal, input }) + + expect(mocks.loadSnapshot).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts new file mode 100644 index 00000000000..66ca180baba --- /dev/null +++ b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts @@ -0,0 +1,232 @@ +/** + * @vitest-environment node + */ +import { WorkflowLockedError } from '@sim/platform-authz/workflow' +import { workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + notify: vi.fn(), + replace: vi.fn(), + validate: vi.fn(), + needsRedeployment: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_UPDATED: 'workflow.updated' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveContext, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/workflows/persistence/replace-normalized-state', () => ({ + replaceWorkflowNormalizedState: mocks.replace, +})) +vi.mock('@/lib/workflows/sanitization/validation', () => ({ + validateWorkflowState: mocks.validate, +})) +vi.mock('@/lib/workflows/deployment-status', () => ({ + checkNeedsRedeployment: mocks.needsRedeployment, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { replaceWorkflowState } from '@/lib/workflows/application/replace-workflow-state' + +const BLOCK = { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, +} + +const context = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1', name: 'Daily digest', workspaceId: 'workspace-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const sessionPrincipal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} + +const input = { workflowId: 'workflow-1', blocks: { 'block-1': BLOCK }, edges: [] } + +describe('replaceWorkflowState', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: [] }) + mocks.replace.mockResolvedValue({ + warnings: [], + state: { blocks: { 'block-1': BLOCK }, edges: [], loops: {}, parallels: {} }, + }) + mocks.needsRedeployment.mockResolvedValue(true) + }) + + it('writes through the shared persistence primitive and reports redeployment drift', async () => { + await expect( + replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + ).resolves.toMatchObject({ + workflowId: 'workflow-1', + blocksCount: 1, + edgesCount: 0, + needsRedeployment: true, + }) + + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + attributedUserId: 'user-1', + }) + ) + }) + + it('derives the audit source from the acting principal and notifies after it', async () => { + await replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.updated', + resourceId: 'workflow-1', + resourceName: 'Daily digest', + metadata: expect.objectContaining({ + operation: 'workflows.state.replace', + op: 'replace_state', + blocksCount: 1, + source: 'session', + }), + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledBefore(mocks.notify) + expect(mocks.notify).toHaveBeenCalledWith('workflow-1') + }) + + it('names the delegated service rather than the principal kind', async () => { + await replaceWorkflowState.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'tool-call-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + }, + input, + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ metadata: expect.objectContaining({ source: 'copilot' }) }) + ) + }) + + it('refuses a role below the operation floor', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('rejects a principal kind the operation does not accept before canonical loading', async () => { + await expect( + replaceWorkflowState.execute({ + principal: { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'someone@example.com', + invitationTokenHash: 'hash', + }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) + + it('conceals an asserted-workspace mismatch as not found', async () => { + mocks.resolveContext.mockRejectedValue( + new OrchestrationError('not_found', 'Workflow not found') + ) + + await expect( + replaceWorkflowState.execute({ + principal: sessionPrincipal, + input: { ...input, assertedWorkspaceId: 'other-workspace' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('refuses a locked workflow before validating or writing', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue( + new WorkflowLockedError('Workflow is locked') + ) + + await expect( + replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + ).rejects.toMatchObject({ code: 'locked' }) + + expect(mocks.validate).not.toHaveBeenCalled() + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('rejects a semantically invalid graph without writing', async () => { + mocks.validate.mockReturnValue({ + valid: false, + errors: ['Edge references an unknown block'], + warnings: [], + }) + + await expect( + replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.replace).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('records neither audit nor notification when the write fails', async () => { + mocks.replace.mockRejectedValue(new Error('constraint violation')) + + await expect( + replaceWorkflowState.execute({ principal: sessionPrincipal, input }) + ).rejects.toThrow('constraint violation') + + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/restore-workflow.test.ts b/apps/sim/lib/workflows/application/restore-workflow.test.ts new file mode 100644 index 00000000000..def7c6931a9 --- /dev/null +++ b/apps/sim/lib/workflows/application/restore-workflow.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { FolderLockedError } from '@sim/platform-authz/workflow' +import { workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + recordAudit: vi.fn(), + resolveContext: vi.fn(), + resolvePermission: vi.fn(), + notify: vi.fn(), + restoreRecord: vi.fn(), + folderIndex: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { WORKFLOW_RESTORED: 'workflow.restored' }, + AuditResourceType: { WORKFLOW: 'workflow' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveArchivedWorkflowApplicationContext: mocks.resolveContext, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) +vi.mock('@/lib/workflows/lifecycle', () => ({ restoreWorkflow: mocks.restoreRecord })) +vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mocks.folderIndex })) + +import { restoreWorkflow } from '@/lib/workflows/application/restore-workflow' + +const archivedWorkflow = { + id: 'workflow-1', + name: 'Daily digest', + workspaceId: 'workspace-1', + folderId: null, + locked: false, +} + +const context = { + workflowId: 'workflow-1', + workflow: archivedWorkflow, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const input = { workflowId: 'workflow-1' } + +describe('restoreWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('write') + workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) + mocks.folderIndex.mockResolvedValue({ pathById: new Map() }) + mocks.restoreRecord.mockResolvedValue({ + restored: true, + workflow: { ...archivedWorkflow, archivedAt: null }, + }) + }) + + it('restores through the lifecycle primitive and projects its own audit row', async () => { + await expect(restoreWorkflow.execute({ principal, input })).resolves.toMatchObject({ + workspaceId: 'workspace-1', + folderPath: '/', + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'workflow.restored', + resourceId: 'workflow-1', + resourceName: 'Daily digest', + metadata: expect.objectContaining({ operation: 'workflows.restore' }), + }) + ) + expect(mocks.recordAudit).toHaveBeenCalledBefore(mocks.notify) + }) + + it('refuses a workflow that is not archived as a conflict', async () => { + mocks.restoreRecord.mockResolvedValue({ restored: false, workflow: archivedWorkflow }) + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'conflict', + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('is not found when the workflow row is gone', async () => { + mocks.restoreRecord.mockResolvedValue({ restored: false, workflow: null }) + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + }) + + it('refuses a locked workflow before restoring', async () => { + mocks.resolveContext.mockResolvedValue({ + ...context, + workflow: { ...archivedWorkflow, locked: true }, + }) + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'locked', + }) + expect(mocks.restoreRecord).not.toHaveBeenCalled() + }) + + it('refuses a locked destination folder before restoring', async () => { + workflowAuthzMockFns.mockAssertFolderMutable.mockRejectedValue( + new FolderLockedError('Folder is locked') + ) + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'locked', + }) + expect(mocks.restoreRecord).not.toHaveBeenCalled() + }) + + it('refuses a role below the operation floor', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect(restoreWorkflow.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + expect(mocks.restoreRecord).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/application/update-workflow-content.test.ts b/apps/sim/lib/workflows/application/update-workflow-content.test.ts index 6379ad2e3f7..5658ca5cff1 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.test.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.test.ts @@ -128,10 +128,35 @@ describe('applyWorkflowVariableOperations', () => { expect(mocks.notify).not.toHaveBeenCalled() }) - it('rejects a non-Copilot principal before canonical loading', async () => { + it('admits a session principal and attributes the audit row to it, not to copilot', async () => { await expect( applyWorkflowVariableOperations.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workflowId: 'workflow-1', + operations: [{ operation: 'add', name: 'threshold', type: 'number', value: '5' }], + }, + }) + ).resolves.toMatchObject({ changed: true }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ source: 'session' }), + }) + ) + }) + + it('rejects a principal kind the operation does not accept, before canonical loading', async () => { + await expect( + applyWorkflowVariableOperations.execute({ + principal: { + kind: 'credential_group_enrollment', + workspaceId: 'workspace-1', + credentialGroupId: 'group-1', + enrollmentId: 'enrollment-1', + email: 'someone@example.com', + invitationTokenHash: 'hash', + }, input: { workflowId: 'workflow-1', operations: [] }, }) ).rejects.toMatchObject({ code: 'forbidden' }) diff --git a/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts new file mode 100644 index 00000000000..7917fb1387b --- /dev/null +++ b/apps/sim/lib/workflows/persistence/replace-normalized-state.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + prepare: vi.fn(), + save: vi.fn(), + extractCustomTools: vi.fn(), +})) + +vi.mock('@/lib/workflows/persistence/prepare-state', () => ({ + prepareWorkflowStateForPersistence: mocks.prepare, +})) +vi.mock('@/lib/workflows/persistence/utils', () => ({ + saveWorkflowToNormalizedTables: mocks.save, +})) +vi.mock('@/lib/workflows/persistence/custom-tools-persistence', () => ({ + extractAndPersistCustomTools: mocks.extractCustomTools, +})) + +import { + replaceWorkflowNormalizedState, + WorkflowStatePersistenceError, +} from '@/lib/workflows/persistence/replace-normalized-state' + +const BLOCK = { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, +} + +const PREPARED = { + blocks: { 'block-1': BLOCK }, + edges: [], + loops: {}, + parallels: {}, +} + +function input(overrides: Record = {}) { + return { + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + attributedUserId: 'user-1', + state: { blocks: { 'block-1': BLOCK }, edges: [] }, + ...overrides, + } as Parameters[0] +} + +describe('replaceWorkflowNormalizedState', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.prepare.mockReturnValue({ state: PREPARED, warnings: [] }) + mocks.save.mockResolvedValue({ success: true }) + mocks.extractCustomTools.mockResolvedValue({ saved: 0, errors: [] }) + }) + + /** + * The two-doors defect: the Copilot edit tool wrote through + * `saveWorkflowToNormalizedTables` directly, so preparation never ran and an + * inline agent-tool secret or a dangling edge reached the tables. + */ + it('prepares the graph before writing it and returns the preparation warnings', async () => { + mocks.prepare.mockReturnValue({ + state: PREPARED, + warnings: ['Dropped edge "edge-9": target block does not exist'], + }) + + const result = await replaceWorkflowNormalizedState(input()) + + expect(mocks.prepare).toHaveBeenCalledWith({ + blocks: { 'block-1': BLOCK }, + edges: [], + }) + expect(mocks.save).toHaveBeenCalledWith( + 'workflow-1', + expect.objectContaining({ blocks: PREPARED.blocks, edges: PREPARED.edges }), + expect.anything() + ) + expect(mocks.prepare).toHaveBeenCalledBefore(mocks.save) + expect(result.warnings).toEqual(['Dropped edge "edge-9": target block does not exist']) + expect(result.state).toBe(PREPARED) + }) + + it('locks the workflow row for update inside the write transaction', async () => { + await replaceWorkflowNormalizedState(input()) + + expect(dbChainMockFns.for).toHaveBeenCalledWith('update') + }) + + it('stamps lastSynced and leaves variables untouched when none are supplied', async () => { + await replaceWorkflowNormalizedState(input()) + + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ lastSynced: expect.any(Date), updatedAt: expect.any(Date) }) + ) + expect(dbChainMockFns.set.mock.calls[0][0]).not.toHaveProperty('variables') + }) + + it('writes variables in the same transaction when they are supplied', async () => { + const variables = { 'var-1': { id: 'var-1', name: 'region', type: 'string', value: 'eu' } } + + await replaceWorkflowNormalizedState(input({ state: { blocks: {}, edges: [], variables } })) + + expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ variables })) + }) + + it('extracts custom tools after the transaction commits', async () => { + await replaceWorkflowNormalizedState(input()) + + expect(mocks.extractCustomTools).toHaveBeenCalledWith( + expect.objectContaining({ blocks: PREPARED.blocks }), + 'workspace-1', + 'user-1' + ) + expect(mocks.save).toHaveBeenCalledBefore(mocks.extractCustomTools) + }) + + /** Pre-existing, deliberate: a stale custom tool never fails a committed graph write. */ + it('keeps custom-tool extraction best-effort', async () => { + mocks.extractCustomTools.mockRejectedValue(new Error('tool table unavailable')) + + await expect(replaceWorkflowNormalizedState(input())).resolves.toMatchObject({ warnings: [] }) + }) + + it('skips custom-tool extraction for a workflow with no workspace', async () => { + await replaceWorkflowNormalizedState(input({ workspaceId: null })) + + expect(mocks.extractCustomTools).not.toHaveBeenCalled() + }) + + it('throws and skips custom-tool extraction when the write fails', async () => { + mocks.save.mockResolvedValue({ success: false, error: 'constraint violation' }) + + await expect(replaceWorkflowNormalizedState(input())).rejects.toBeInstanceOf( + WorkflowStatePersistenceError + ) + expect(mocks.extractCustomTools).not.toHaveBeenCalled() + }) +}) From 1b40a90cd6c53ff2156e4a74daca6f798975f391 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 12:01:53 -0700 Subject: [PATCH 04/55] test(workflows): pin the internal graph-write door and the v2 list scope Characterize saveWorkflowNormalizedState's statuses, messages, and notification after the persistence extraction, and cover the new scope filter on GET /api/v2/workflows including a cursor replayed under a different scope. --- apps/sim/app/api/v2/workflows/route.test.ts | 47 +++++ .../save-workflow-normalized-state.test.ts | 169 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 6db1fa42ef1..78cc7a12fed 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -102,6 +102,53 @@ describe('/api/v2/workflows', () => { }) }) + it('lists the active scope by default and forwards an explicit archived scope', async () => { + await GET(new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`)) + expect(mocks.listWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ scope: 'active' }) }) + ) + + await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&scope=archived` + ) + ) + expect(mocks.listWorkflows).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ scope: 'archived' }) }) + ) + }) + + it('rejects a scope the surface does not serve', async () => { + const response = await GET( + new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&scope=all`) + ) + + expect(response.status).toBe(400) + expect(mocks.listWorkflows).not.toHaveBeenCalled() + }) + + it('refuses a cursor replayed under a different scope', async () => { + mocks.listWorkflows.mockResolvedValueOnce({ + workflows: [WORKFLOW], + nextCursorKeys: [1, WORKFLOW.id], + sortBy: 'position', + sortOrder: 'asc', + }) + const first = await GET( + new NextRequest(`http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}`) + ) + const { nextCursor } = await first.json() + expect(nextCursor).toEqual(expect.any(String)) + + const replayed = await GET( + new NextRequest( + `http://localhost/api/v2/workflows?workspaceId=${WORKSPACE_ID}&scope=archived&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(replayed.status).toBe(400) + }) + it('authenticates and rate limits before parsing list input', async () => { const response = await GET(new NextRequest('http://localhost/api/v2/workflows')) diff --git a/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts new file mode 100644 index 00000000000..f648e5fc0ac --- /dev/null +++ b/apps/sim/lib/workflows/persistence/save-workflow-normalized-state.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + * + * Characterization of the legacy internal door's wire behavior. It now delegates + * the write to `replaceWorkflowNormalizedState`, so these assertions are what + * proves the extraction did not move a status or a message. + */ +import { WorkflowLockedError } from '@sim/platform-authz/workflow' +import { workflowAuthzMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + replace: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@/lib/workflows/persistence/replace-normalized-state', async () => { + class WorkflowStatePersistenceError extends Error { + constructor(readonly detail: string) { + super('Failed to save workflow state') + this.name = 'WorkflowStatePersistenceError' + } + } + return { + WorkflowStatePersistenceError, + replaceWorkflowNormalizedState: mocks.replace, + } +}) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowUpdated: mocks.notify })) + +import { WorkflowStatePersistenceError } from '@/lib/workflows/persistence/replace-normalized-state' +import { saveWorkflowNormalizedState } from '@/lib/workflows/persistence/save-normalized-state' + +const STATE = { + blocks: { + 'block-1': { + id: 'block-1', + type: 'starter', + name: 'Start', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + enabled: true, + }, + }, + edges: [], +} as never + +function params(overrides: Record = {}) { + return { + requestId: 'request-1', + workflowId: 'workflow-1', + userId: 'user-1', + state: STATE, + ...overrides, + } as Parameters[0] +} + +describe('saveWorkflowNormalizedState', () => { + beforeEach(() => { + vi.clearAllMocks() + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: true, + status: 200, + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + workspacePermission: 'write', + }) + workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) + mocks.replace.mockResolvedValue({ warnings: ['dropped an edge'], state: STATE }) + }) + + it('returns success with the preparation warnings and notifies once', async () => { + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: true, + warnings: ['dropped an edge'], + }) + + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'request-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + attributedUserId: 'user-1', + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workflow-1') + }) + + it('reuses an authorization decision the caller already resolved', async () => { + await saveWorkflowNormalizedState( + params({ + authorization: { + allowed: true, + status: 200, + workflow: { id: 'workflow-1', workspaceId: 'workspace-2' }, + workspacePermission: 'admin', + }, + }) + ) + + expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled() + expect(mocks.replace).toHaveBeenCalledWith( + expect.objectContaining({ workspaceId: 'workspace-2' }) + ) + }) + + it('reports a missing workflow as 404 without writing', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 404, + workflow: null, + }) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 404, + error: 'Workflow not found', + }) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('passes the authorization status and message straight through on a denial', async () => { + workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({ + allowed: false, + status: 403, + message: 'Access denied', + workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, + }) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 403, + error: 'Access denied', + }) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('reports a locked workflow as 423 without writing', async () => { + workflowAuthzMockFns.mockAssertWorkflowMutable.mockRejectedValue( + new WorkflowLockedError('Workflow is locked') + ) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 423, + error: 'Workflow is locked', + }) + expect(mocks.replace).not.toHaveBeenCalled() + }) + + it('reports a persistence failure as 500 with its detail and does not notify', async () => { + mocks.replace.mockRejectedValue(new WorkflowStatePersistenceError('constraint violation')) + + await expect(saveWorkflowNormalizedState(params())).resolves.toEqual({ + success: false, + status: 500, + error: 'Failed to save workflow state', + details: 'constraint violation', + }) + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('propagates an unclassified fault rather than turning it into a status', async () => { + mocks.replace.mockRejectedValue(new Error('pool exhausted')) + + await expect(saveWorkflowNormalizedState(params())).rejects.toThrow('pool exhausted') + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) From 332fbeeee260f0a003170076c0e070814fb1b908 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 20 Aug 2026 12:02:51 -0700 Subject: [PATCH 05/55] feat(api): add v2 block, tool, connector-type, and enrichment catalogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds six read endpoints under /api/v2 that publish Sim's code-defined catalogs: GET /blocks, GET /blocks/{blockId}, GET /tools, GET /tools/{toolId}, GET /connector-types, and GET /enrichments. These read like static reference data and are not. What a caller may place is decided per workspace by its permission-group integration allowlist, per organization by which unreleased blocks have been revealed, per deployment by ALLOWED_INTEGRATIONS, and per workspace again by the workflows it has deployed as blocks. So all six are plain defineWorkspaceOperation reads at minimumRole 'read' with workspaceApiKey 'allow' — the exact policy of credentials.providers.list — and every response keeps Cache-Control: private, no-store, because an unrevealed preview block's existence must not leak across organizations through a shared cache. Trigger blocks ride as ?capability=trigger rather than a second endpoint, and workspace custom blocks ride inside /blocks discriminated by `source`, so "what may I place?" stays a one-call question. The block projection is extracted out of the Copilot get_blocks_metadata tool and rewritten onto @/tools/metadata and @/tools/metadata-outputs. That cuts the tool's own @/tools/registry edge as a side effect: its module graph drops from 6,756 to 1,318, and the new routes land at 1,673-1,734, next to the shipped /v2/credentials/providers baseline of 1,668. Supporting changes: - scripts/sync-tool-metadata.ts derives hostedApiKey ('always' | 'conditional' | 'none') from each tool's `hosting`. The config itself stays excluded because it holds closures, but "does Sim host the key" is a first-order authoring question, so the answer is emitted. - getCopilotToolDescription takes hostedApiKey as an option instead of reading `hosting` off the tool, so both an executable ToolConfig and the generated metadata can answer it through one shared derivation. - principalUserId / allowedIntegrationTypes move out of lib/credentials/application/provider-catalog.ts into lib/integrations/principal-scope.server.ts. Two copies of the workspace integration gate would diverge first on the workspace-key path, which has no user for permission groups to key on. - scripts/check-tool-registry-boundary.ts walked page.tsx/layout.tsx under app/workspace only, so a route importing the executable registry passed green. It now walks a list of entry sources, seeded with the four catalog route subtrees and the shared projection barrel. Routes are covered per subtree rather than wholesale because 122 of ~1,130 route files legitimately execute tools. Registry sweeps parse every block, tool, connector type, and enrichment through its published response schema and compare against the wire round-trip. They caught a real drift while being written: an operation's inputs were typed as a union of the tool-param and block-input shapes, and the union resolved to whichever member matched first, silently dropping a block input's `schema`. --- apps/docs/content/docs/en/cli/blocks.mdx | 46 + apps/docs/content/docs/en/cli/commands.mdx | 4 + .../content/docs/en/cli/connector-types.mdx | 24 + apps/docs/content/docs/en/cli/enrichments.mdx | 24 + apps/docs/content/docs/en/cli/meta.json | 4 + apps/docs/content/docs/en/cli/reference.mdx | 127 + apps/docs/content/docs/en/cli/tools.mdx | 45 + apps/docs/openapi-v2-resources.json | 5787 ++++++++++++----- apps/sim/app/api/v2/blocks/[blockId]/route.ts | 23 + apps/sim/app/api/v2/blocks/route.test.ts | 246 + apps/sim/app/api/v2/blocks/route.ts | 56 + .../app/api/v2/connector-types/route.test.ts | 117 + apps/sim/app/api/v2/connector-types/route.ts | 20 + apps/sim/app/api/v2/enrichments/route.test.ts | 106 + apps/sim/app/api/v2/enrichments/route.ts | 20 + apps/sim/app/api/v2/lib/catalog.ts | 12 + apps/sim/app/api/v2/tools/[toolId]/route.ts | 23 + apps/sim/app/api/v2/tools/route.test.ts | 181 + apps/sim/app/api/v2/tools/route.ts | 49 + .../v2/__tests__/list-pagination.test.ts | 26 + apps/sim/lib/api/contracts/v2/catalog.ts | 816 +++ .../lib/api/contracts/v2/openapi/resources.ts | 321 +- .../catalog/application/catalog-context.ts | 94 + .../lib/catalog/application/catalog-page.ts | 76 + .../catalog/application/catalog-reads.test.ts | 434 ++ apps/sim/lib/catalog/application/get-block.ts | 48 + apps/sim/lib/catalog/application/get-tool.ts | 47 + .../lib/catalog/application/list-blocks.ts | 91 + .../application/list-connector-types.ts | 47 + .../catalog/application/list-enrichments.ts | 40 + .../application/list-registries.test.ts | 111 + .../sim/lib/catalog/application/list-tools.ts | 75 + .../catalog/application/operations.test.ts | 56 + .../sim/lib/catalog/application/operations.ts | 54 + .../sim/lib/catalog/application/tool-scope.ts | 31 + .../lib/catalog/projection/block-detail.ts | 432 ++ .../lib/catalog/projection/block-summary.ts | 124 + .../catalog/projection/catalog-sweep.test.ts | 172 + .../lib/catalog/projection/connector-type.ts | 128 + apps/sim/lib/catalog/projection/enrichment.ts | 79 + apps/sim/lib/catalog/projection/index.ts | 15 + apps/sim/lib/catalog/projection/subblock.ts | 285 + apps/sim/lib/catalog/projection/tool.ts | 148 + .../sim/lib/catalog/registry-boundary.test.ts | 114 + apps/sim/lib/copilot/chat/payload.ts | 2 + .../lib/copilot/tools/descriptions.test.ts | 29 +- apps/sim/lib/copilot/tools/descriptions.ts | 18 +- .../get-blocks-metadata-projection.test.ts | 91 + .../blocks/get-blocks-metadata-tool.test.ts | 6 +- .../server/blocks/get-blocks-metadata-tool.ts | 685 +- apps/sim/lib/copilot/vfs/serializers.ts | 6 +- .../application/provider-catalog.ts | 25 +- .../integrations/principal-scope.server.ts | 56 + apps/sim/tools/generated/tool-metadata.ts | 2 +- apps/sim/tools/hosted-api-key.ts | 22 + apps/sim/tools/metadata.ts | 7 + apps/sim/vitest.setup.ts | 2 + packages/sim-cli/src/generated/v2-api.ts | 602 ++ scripts/check-api-validation-contracts.ts | 4 +- ...check-tool-registry-boundary.baseline.json | 117 +- scripts/check-tool-registry-boundary.ts | 126 +- scripts/openapi/documents.test.ts | 4 +- scripts/sync-tool-metadata.ts | 11 + 63 files changed, 10208 insertions(+), 2385 deletions(-) create mode 100644 apps/docs/content/docs/en/cli/blocks.mdx create mode 100644 apps/docs/content/docs/en/cli/connector-types.mdx create mode 100644 apps/docs/content/docs/en/cli/enrichments.mdx create mode 100644 apps/docs/content/docs/en/cli/tools.mdx create mode 100644 apps/sim/app/api/v2/blocks/[blockId]/route.ts create mode 100644 apps/sim/app/api/v2/blocks/route.test.ts create mode 100644 apps/sim/app/api/v2/blocks/route.ts create mode 100644 apps/sim/app/api/v2/connector-types/route.test.ts create mode 100644 apps/sim/app/api/v2/connector-types/route.ts create mode 100644 apps/sim/app/api/v2/enrichments/route.test.ts create mode 100644 apps/sim/app/api/v2/enrichments/route.ts create mode 100644 apps/sim/app/api/v2/lib/catalog.ts create mode 100644 apps/sim/app/api/v2/tools/[toolId]/route.ts create mode 100644 apps/sim/app/api/v2/tools/route.test.ts create mode 100644 apps/sim/app/api/v2/tools/route.ts create mode 100644 apps/sim/lib/api/contracts/v2/catalog.ts create mode 100644 apps/sim/lib/catalog/application/catalog-context.ts create mode 100644 apps/sim/lib/catalog/application/catalog-page.ts create mode 100644 apps/sim/lib/catalog/application/catalog-reads.test.ts create mode 100644 apps/sim/lib/catalog/application/get-block.ts create mode 100644 apps/sim/lib/catalog/application/get-tool.ts create mode 100644 apps/sim/lib/catalog/application/list-blocks.ts create mode 100644 apps/sim/lib/catalog/application/list-connector-types.ts create mode 100644 apps/sim/lib/catalog/application/list-enrichments.ts create mode 100644 apps/sim/lib/catalog/application/list-registries.test.ts create mode 100644 apps/sim/lib/catalog/application/list-tools.ts create mode 100644 apps/sim/lib/catalog/application/operations.test.ts create mode 100644 apps/sim/lib/catalog/application/operations.ts create mode 100644 apps/sim/lib/catalog/application/tool-scope.ts create mode 100644 apps/sim/lib/catalog/projection/block-detail.ts create mode 100644 apps/sim/lib/catalog/projection/block-summary.ts create mode 100644 apps/sim/lib/catalog/projection/catalog-sweep.test.ts create mode 100644 apps/sim/lib/catalog/projection/connector-type.ts create mode 100644 apps/sim/lib/catalog/projection/enrichment.ts create mode 100644 apps/sim/lib/catalog/projection/index.ts create mode 100644 apps/sim/lib/catalog/projection/subblock.ts create mode 100644 apps/sim/lib/catalog/projection/tool.ts create mode 100644 apps/sim/lib/catalog/registry-boundary.test.ts create mode 100644 apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts create mode 100644 apps/sim/lib/integrations/principal-scope.server.ts create mode 100644 apps/sim/tools/hosted-api-key.ts diff --git a/apps/docs/content/docs/en/cli/blocks.mdx b/apps/docs/content/docs/en/cli/blocks.mdx new file mode 100644 index 00000000000..cc3a2c87b18 --- /dev/null +++ b/apps/docs/content/docs/en/cli/blocks.mdx @@ -0,0 +1,46 @@ +--- +title: Blocks +description: Manage blocks — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Get block + +```bash +sim blocks get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `blockId` | Yes | Block type identifier. | + + + +## List blocks + +```bash +sim blocks list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the block id, name, and description. | +| `--category ` | No | Restrict to one toolbar category. Accepted values: `blocks`, `tools`, `triggers`. | +| `--capability ` | No | Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields. Accepted values: `trigger`. | +| `--source ` | No | Restrict to shipped blocks or to this workspace’s deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + diff --git a/apps/docs/content/docs/en/cli/commands.mdx b/apps/docs/content/docs/en/cli/commands.mdx index cbe18f15bbf..f92e0fc1390 100644 --- a/apps/docs/content/docs/en/cli/commands.mdx +++ b/apps/docs/content/docs/en/cli/commands.mdx @@ -32,8 +32,11 @@ These apply to every command, and may be written before or after it. | --- | --- | | [`sim audit-logs`](/cli/audit-logs) | Manage audit logs | | [`sim billing`](/cli/billing) | Manage billing | +| [`sim blocks`](/cli/blocks) | Manage blocks | +| [`sim connector-types`](/cli/connector-types) | Manage connector types | | [`sim credentials`](/cli/credentials) | Manage credentials | | [`sim custom-tools`](/cli/custom-tools) | Manage custom tools | +| [`sim enrichments`](/cli/enrichments) | Manage enrichments | | [`sim files`](/cli/files) | Manage files | | [`sim knowledge`](/cli/knowledge) | Manage knowledge | | [`sim logs`](/cli/logs) | Manage logs | @@ -41,6 +44,7 @@ These apply to every command, and may be written before or after it. | [`sim secrets`](/cli/secrets) | Manage secrets | | [`sim skills`](/cli/skills) | Manage skills | | [`sim tables`](/cli/tables) | Manage tables | +| [`sim tools`](/cli/tools) | Manage tools | | [`sim workflows`](/cli/workflows) | Manage workflows | | [`sim workspaces`](/cli/workspaces) | Manage workspaces | diff --git a/apps/docs/content/docs/en/cli/connector-types.mdx b/apps/docs/content/docs/en/cli/connector-types.mdx new file mode 100644 index 00000000000..2823ed60e0c --- /dev/null +++ b/apps/docs/content/docs/en/cli/connector-types.mdx @@ -0,0 +1,24 @@ +--- +title: Connector Types +description: Manage connector types — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## List connector types + +```bash +sim connector-types list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the connector name. | + + diff --git a/apps/docs/content/docs/en/cli/enrichments.mdx b/apps/docs/content/docs/en/cli/enrichments.mdx new file mode 100644 index 00000000000..6708b157d51 --- /dev/null +++ b/apps/docs/content/docs/en/cli/enrichments.mdx @@ -0,0 +1,24 @@ +--- +title: Enrichments +description: Manage enrichments — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## List enrichments + +```bash +sim enrichments list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the enrichment name. | + + diff --git a/apps/docs/content/docs/en/cli/meta.json b/apps/docs/content/docs/en/cli/meta.json index a18d504f907..4394c72c94d 100644 --- a/apps/docs/content/docs/en/cli/meta.json +++ b/apps/docs/content/docs/en/cli/meta.json @@ -13,8 +13,11 @@ "commands", "audit-logs", "billing", + "blocks", + "connector-types", "credentials", "custom-tools", + "enrichments", "files", "knowledge", "logs", @@ -22,6 +25,7 @@ "secrets", "skills", "tables", + "tools", "workflows", "workspaces", "reference" diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index a0f59c28f6d..cd8cbd47aa9 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -214,6 +214,70 @@ sim billing logs [options] +## sim blocks + +### sim blocks get + +Get Block + +```bash +sim blocks get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `blockId` | Yes | Block type identifier. | + + + +### sim blocks list + +List Blocks + +```bash +sim blocks list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the block id, name, and description. | +| `--category ` | No | Restrict to one toolbar category. Accepted values: `blocks`, `tools`, `triggers`. | +| `--capability ` | No | Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields. Accepted values: `trigger`. | +| `--source ` | No | Restrict to shipped blocks or to this workspace’s deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + +## sim connector-types + +### sim connector-types list + +List Connector Types + +```bash +sim connector-types list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the connector name. | + + + ## sim credentials Also spelled `sim credential`. @@ -485,6 +549,26 @@ sim custom-tools update [options] +## sim enrichments + +### sim enrichments list + +List Enrichments + +```bash +sim enrichments list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the enrichment name. | + + + ## sim files Also spelled `sim file`. @@ -3099,6 +3183,49 @@ sim tables mkdir +## sim tools + +### sim tools get + +Get Tool + +```bash +sim tools get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `toolId` | Yes | Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id. | + + + +### sim tools list + +List Tools + +```bash +sim tools list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the tool id, name, and description. | +| `--hosted-api-key ` | No | Restrict to tools by how their API key is supplied. Accepted values: `always`, `conditional`, `none`. | +| `--oauth-provider ` | No | Restrict to tools that authenticate against this OAuth service. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + ## sim workflows Also spelled `sim workflow`. diff --git a/apps/docs/content/docs/en/cli/tools.mdx b/apps/docs/content/docs/en/cli/tools.mdx new file mode 100644 index 00000000000..bc43a7dd711 --- /dev/null +++ b/apps/docs/content/docs/en/cli/tools.mdx @@ -0,0 +1,45 @@ +--- +title: Tools +description: Manage tools — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Get tool + +```bash +sim tools get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `toolId` | Yes | Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id. | + + + +## List tools + +```bash +sim tools list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--search ` | No | Case-insensitive substring match against the tool id, name, and description. | +| `--hosted-api-key ` | No | Restrict to tools by how their API key is supplied. Accepted values: `always`, `conditional`, `none`. | +| `--oauth-provider ` | No | Restrict to tools that authenticate against this OAuth service. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index f34b36a578e..6a8ab2ece96 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Workspace Resources", - "description": "Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, and write-only secrets.", + "description": "Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, write-only secrets, and the block, tool, connector-type, and enrichment catalogs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -44,6 +44,10 @@ { "name": "Secrets", "description": "Set and manage write-only workspace and personal secret values." + }, + { + "name": "Catalog", + "description": "Discover the blocks, built-in tools, knowledge-base connector types, and table enrichments available in a workspace." } ], "security": [ @@ -2357,1129 +2361,1017 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } - }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { + "/api/v2/blocks": { + "get": { + "operationId": "listBlocks", + "summary": "List Blocks", + "description": "List the blocks available in a workspace, built-in and workspace-deployed alike, discriminated by `source`. Availability is caller-specific: the workspace’s integration allowlist, the organization’s revealed preview blocks, and the deployment’s allowlist all narrow the result. Use `capability=trigger` for the blocks that can start a workflow. Summaries name their tools and operations by id — resolve one with Get Block or Get Tool.", + "tags": ["Catalog"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." } - } - } - }, - "Unauthorized": { - "description": "The API key is missing or invalid.", - "content": { - "application/json": { + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the block id, name, and description.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "API key required" - } + "description": "Case-insensitive substring match against the block id, name, and description.", + "type": "string", + "minLength": 1, + "maxLength": 200 } - } - } - }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { + }, + { + "name": "category", + "in": "query", + "required": false, + "description": "Restrict to one toolbar category.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" - } - } + "description": "Restrict to one toolbar category.", + "type": "string", + "enum": ["blocks", "tools", "triggers"] } - } - } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { + }, + { + "name": "capability", + "in": "query", + "required": false, + "description": "Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "description": "Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields.", + "type": "string", + "enum": ["trigger"] } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { + }, + { + "name": "source", + "in": "query", + "required": false, + "description": "Restrict to shipped blocks or to this workspace’s deployed custom blocks.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "API key name already exists" - } + "description": "Restrict to shipped blocks or to this workspace’s deployed custom blocks.", + "type": "string", + "enum": ["builtin", "custom"] } - } - } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } + "default": "id", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["id", "name", "category"] } - } - } - }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } - }, - "content": { - "application/json": { + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" - } - } + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] } - } - } - }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum blocks to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" - } + "default": 50, + "description": "Maximum blocks to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 } } - } - }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + ], + "responses": { + "200": { + "description": "A page of blocks available in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListBlocksResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } } }, - "schemas": { - "V2Error": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." + "/api/v2/blocks/{blockId}": { + "get": { + "operationId": "getBlock", + "summary": "Get Block", + "description": "Read one block’s full configuration shape: its fields and their conditions, its operations with the tool each runs, every tool’s parameters and outputs, and its triggers. A block this caller cannot see answers 404, identically to one that does not exist.", + "tags": ["Catalog"], + "parameters": [ + { + "name": "blockId", + "in": "path", + "required": true, + "description": "Block type identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Block type identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." + } + } + ], + "responses": { + "200": { + "description": "The block.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" }, - "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." - } - }, - "required": ["error"], - "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", - "examples": [ - { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetBlockResponse" + } + } } - } - ] - }, - "V2Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." }, - "name": { - "type": "string", - "description": "Workspace display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "color": { - "type": "string", - "description": "Workspace color as a hexadecimal color value." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "logoUrl": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workspace logo URL, or null when none is configured." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "memberCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of effective members, including inherited organization administrators." + "404": { + "$ref": "#/components/responses/NotFound" }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the workspace was created." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the workspace was last updated." - } - }, - "required": ["id", "name", "color", "logoUrl", "memberCount", "createdAt", "updatedAt"], - "additionalProperties": false, - "title": "Workspace", - "description": "Public metadata for an accessible workspace." - }, - "GetWorkspaceResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Workspace" + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get workspace response", - "description": "Public metadata for one workspace.", - "examples": [ + } + } + }, + "/api/v2/tools": { + "get": { + "operationId": "listTools", + "summary": "List Tools", + "description": "List the built-in tools available in a workspace. Built-in tools only: a workspace’s MCP tools are discovered per server on List MCP Server Tools, and its code-backed custom tools are on List Custom Tools. A tool is available when a block the caller can see exposes it, so the same allowlist and visibility rules as List Blocks apply.", + "tags": ["Catalog"], + "parameters": [ { - "data": { - "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Engineering", - "color": "#33C482", - "logoUrl": null, - "memberCount": 14, - "createdAt": "2026-01-15T10:30:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." } - } - ] - }, - "V2WorkspaceMember": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", - "description": "Member email address and public member identifier." }, - "name": { - "type": "string", - "description": "Member display name." + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the tool id, name, and description.", + "schema": { + "description": "Case-insensitive substring match against the tool id, name, and description.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } }, - "image": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Member profile image URL, or null when absent." + { + "name": "hostedApiKey", + "in": "query", + "required": false, + "description": "Restrict to tools by how their API key is supplied.", + "schema": { + "description": "Restrict to tools by how their API key is supplied.", + "type": "string", + "enum": ["always", "conditional", "none"] + } }, - "role": { - "type": "string", - "enum": ["admin", "write", "read"], - "description": "Effective role in the workspace." + { + "name": "oauthProvider", + "in": "query", + "required": false, + "description": "Restrict to tools that authenticate against this OAuth service.", + "schema": { + "description": "Restrict to tools that authenticate against this OAuth service.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } }, - "isExternal": { - "type": "boolean", - "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "id", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["id", "name"] + } }, - "joinedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when access was granted." - } - }, - "required": ["email", "name", "image", "role", "isExternal", "joinedAt"], - "additionalProperties": false, - "title": "Workspace member", - "description": "An effective workspace member and their public access role." - }, - "ListWorkspaceMembersResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2WorkspaceMember" - }, - "description": "Items in the current page." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum tools to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum tools to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "A page of built-in tools available in the workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List workspace members response", - "description": "A cursor-paginated page of effective workspace members.", - "examples": [ - { - "data": [ - { - "email": "jane@example.com", - "name": "Jane Smith", - "image": null, - "role": "admin", - "isExternal": false, - "joinedAt": "2026-01-15T10:30:00.000Z" + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListToolsResponse" + } } - ], - "nextCursor": null - } - ] - }, - "V2McpServer": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique server identifier derived from the workspace and endpoint URL." - }, - "name": { - "type": "string", - "description": "Server display name." - }, - "description": { - "description": "Optional server description.", - "type": "string" + } }, - "transport": { - "default": "streamable-http", - "description": "Transport used to communicate with the server.", - "type": "string", - "enum": ["streamable-http"] + "400": { + "$ref": "#/components/responses/BadRequest" }, - "authType": { - "description": "Authentication method used by the server.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "url": { - "description": "Server endpoint URL.", - "type": "string" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "timeout": { - "description": "Per-request timeout in milliseconds.", - "type": "number" + "404": { + "$ref": "#/components/responses/NotFound" }, - "retries": { - "description": "Number of retries attempted per request.", - "type": "number" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "enabled": { - "type": "boolean", - "description": "Whether the server tools are available to workflows." + "500": { + "$ref": "#/components/responses/InternalError" }, - "connectionStatus": { - "description": "Result of the most recent connection attempt. Registration and re-registration establish no connection — the auth-type probe they may send does not count as one — so a server begins, and returns to, `disconnected` until a tool discovery runs.", - "type": "string", - "enum": ["connected", "disconnected", "error"] + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/tools/{toolId}": { + "get": { + "operationId": "getTool", + "summary": "Get Tool", + "description": "Read one built-in tool’s declared parameters and outputs. An unversioned name resolves to the newest version — `gmail_send` answers with `gmail_send_v2` — and the returned `id` is always the resolved one, so a caller can see which version it got.", + "tags": ["Catalog"], + "parameters": [ + { + "name": "toolId", + "in": "path", + "required": true, + "description": "Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id." + } }, - "lastError": { - "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", - "anyOf": [ - { - "type": "string" + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." + } + } + ], + "responses": { + "200": { + "description": "The tool.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ] + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetToolResponse" + } + } + } }, - "toolCount": { - "description": "Number of tools discovered on the server.", - "type": "number" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "lastToolsRefresh": { - "description": "ISO 8601 timestamp of the most recent tool-list refresh.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "lastConnected": { - "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "createdAt": { - "description": "ISO 8601 timestamp when the server was registered.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "404": { + "$ref": "#/components/responses/NotFound" }, - "updatedAt": { - "description": "ISO 8601 timestamp when the server was last updated.", - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier, when configured.", - "type": "string" - }, - "hasHeaders": { - "type": "boolean", - "description": "Whether any request headers are configured." + "500": { + "$ref": "#/components/responses/InternalError" }, - "headerNames": { - "type": "array", - "items": { + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/connector-types": { + "get": { + "operationId": "listConnectorTypes", + "summary": "List Connector Types", + "description": "List every knowledge-base connector type and the source configuration each accepts. Two properties of a config field decide how its value is sent and are not inferable from the rest: a field with `multi: true` stores a `string[]` rather than a `string`, and a `canonicalParamId` links a picker field to a manual-entry field that write the SAME configuration key — send exactly one of the pair, keyed by `canonicalParamId` rather than by the field's own `id`. The bounded set is returned in one page; `nextCursor` is always null.", + "tags": ["Catalog"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { "type": "string", - "description": "Configured header name." - }, - "description": "Names of configured request headers. Header values are never returned." + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." + } }, - "hasOauthClientSecret": { - "type": "boolean", - "description": "Whether an OAuth client secret is stored. The value is never returned." + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the connector name.", + "schema": { + "description": "Case-insensitive substring match against the connector name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } } - }, - "required": [ - "id", - "name", - "transport", - "enabled", - "createdAt", - "updatedAt", - "hasHeaders", - "headerNames", - "hasOauthClientSecret" ], - "additionalProperties": false, - "title": "MCP server", - "description": "Public MCP server configuration without write-only credential values." - }, - "ListMcpServersResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2McpServer" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + "responses": { + "200": { + "description": "The connector-type catalog.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" }, - { - "type": "null" + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List MCP servers response", - "description": "MCP servers registered in the workspace.", - "examples": [ - { - "data": [ - { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListConnectorTypesResponse" + } } - ], - "nextCursor": null - } - ] - }, - "CreateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create MCP server response", - "description": "The registered MCP server without write-only credentials.", - "examples": [ - { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "disconnected", - "lastError": null, - "toolCount": 0, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false } - } - ] - }, - "CreateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to register the server." }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "transport": { - "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] + "403": { + "$ref": "#/components/responses/Forbidden" }, - "url": { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." + "404": { + "$ref": "#/components/responses/NotFound" }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] + "429": { + "$ref": "#/components/responses/RateLimited" }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { - "type": "string", - "minLength": 1 - }, - "additionalProperties": { + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/enrichments": { + "get": { + "operationId": "listEnrichments", + "summary": "List Enrichments", + "description": "List every code-defined table enrichment, the per-row inputs it needs, the columns it fills, and the providers it draws from. Providers are listed in the order they are attempted: the first to return a non-empty result fills the cell. The bounded set is returned in one page; `nextCursor` is always null.", + "tags": ["Catalog"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.", + "schema": { "type": "string", - "description": "Header value sent to the MCP server." + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains." } }, - "timeout": { - "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 - }, - "retries": { - "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 - }, - "enabled": { - "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", - "default": true, - "type": "boolean" - }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", - "anyOf": [ - { - "type": "string", - "maxLength": 512 - }, - { - "type": "null" - } - ] - }, - "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", - "writeOnly": true, - "anyOf": [ - { - "type": "string", - "maxLength": 2048 - }, - { - "type": "null" - } - ] - } - }, - "required": ["workspaceId", "name", "url"], - "additionalProperties": false, - "title": "Create MCP server request", - "description": "Configuration for a new MCP server.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Docs server", - "url": "https://mcp.example.com/sse", - "authType": "headers", - "headers": { - "Authorization": "Bearer YOUR_TOKEN" - } - } - ] - }, - "GetMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get MCP server response", - "description": "One MCP server without write-only credentials.", - "examples": [ - { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": true, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false - } - } - ] - }, - "UpdateMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServer" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update MCP server response", - "description": "The updated MCP server.", - "examples": [ { - "data": { - "id": "mcp-3f7a9c21", - "name": "Docs server", - "description": "Internal documentation tools", - "transport": "streamable-http", - "authType": "headers", - "url": "https://mcp.example.com/sse", - "timeout": 30000, - "retries": 3, - "enabled": false, - "connectionStatus": "connected", - "lastError": null, - "toolCount": 7, - "lastToolsRefresh": "2026-06-20T14:02:11.000Z", - "lastConnected": "2026-06-20T14:02:11.000Z", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "hasHeaders": true, - "headerNames": ["Authorization"], - "hasOauthClientSecret": false + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the enrichment name.", + "schema": { + "description": "Case-insensitive substring match against the enrichment name.", + "type": "string", + "minLength": 1, + "maxLength": 200 } } - ] - }, - "UpdateMcpServerRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the MCP server." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Server display name." - }, - "description": { - "description": "Optional server description.", - "type": "string", - "maxLength": 2000 - }, - "transport": { - "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", - "default": "streamable-http", - "type": "string", - "enum": ["streamable-http"] - }, - "url": { - "description": "Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints.", - "type": "string", - "minLength": 1, - "maxLength": 2048 - }, - "authType": { - "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", - "type": "string", - "enum": ["none", "headers", "oauth"] - }, - "headers": { - "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", - "writeOnly": true, - "type": "object", - "propertyNames": { - "type": "string", - "minLength": 1 + ], + "responses": { + "200": { + "description": "The enrichment catalog.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } }, - "additionalProperties": { - "type": "string", - "description": "Header value sent to the MCP server." + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListEnrichmentsResponse" + } + } } }, - "timeout": { - "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", - "default": 30000, - "type": "integer", - "minimum": 1000, - "maximum": 300000 + "400": { + "$ref": "#/components/responses/BadRequest" }, - "retries": { - "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", - "default": 3, - "type": "integer", - "minimum": 0, - "maximum": 10 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "enabled": { - "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", - "default": true, - "type": "boolean" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "oauthClientId": { - "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", - "anyOf": [ - { - "type": "string", - "maxLength": 512 - }, - { - "type": "null" - } - ] + "404": { + "$ref": "#/components/responses/NotFound" }, - "oauthClientSecret": { - "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", - "writeOnly": true, - "anyOf": [ - { - "type": "string", - "maxLength": 2048 - }, - { - "type": "null" - } - ] - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Update MCP server request", - "description": "MCP server fields to change; omitted fields retain their stored values.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "enabled": false - } - ] - }, - "V2McpServerDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted MCP server." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the server was deleted." + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete MCP server data", - "description": "MCP server deletion acknowledgement." + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } }, - "DeleteMcpServerResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2McpServerDeleteData" + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } + } } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete MCP server response", - "description": "Acknowledgement that the MCP server was deleted.", - "examples": [ - { - "data": { - "id": "mcp-3f7a9c21", - "deleted": true + } + }, + "Unauthorized": { + "description": "The API key is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "API key required" + } } } - ] + } }, - "V2McpTool": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Tool name, as the MCP server reports it." - }, - "description": { - "description": "Tool description reported by the server.", - "type": "string" - }, - "inputSchema": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "object", - "description": "JSON Schema type of the argument object. MCP requires `object`." - }, - "properties": { - "description": "Argument schemas keyed by argument name.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Server-defined JSON Schema for one tool argument." - } - }, - "required": { - "description": "Names of the arguments the tool requires.", - "type": "array", - "items": { - "type": "string", - "description": "Name of a required argument." + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" } } - }, - "required": ["type"], - "additionalProperties": { - "description": "Additional JSON Schema keyword reported by the server." - }, - "description": "JSON Schema for the tool's arguments, as reported by the server." - }, - "serverId": { - "type": "string", - "description": "Identifier of the MCP server exposing the tool." - }, - "serverName": { - "type": "string", - "description": "Display name of the MCP server exposing the tool." + } } - }, - "required": ["name", "inputSchema", "serverId", "serverName"], - "additionalProperties": false, - "title": "MCP tool", - "description": "A tool exposed by a registered MCP server." + } }, - "ListMcpServerToolsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2McpTool" + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" } - ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List MCP server tools response", - "description": "Tools exposed by the MCP server.", - "examples": [ - { - "data": [ - { - "name": "search_docs", - "description": "Search the internal documentation", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search terms" - } - }, - "required": ["query"] - }, - "serverId": "mcp-3f7a9c21", - "serverName": "Docs server" + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "API key name already exists" } - ], - "nextCursor": null + } } - ] + } }, - "V2SkillSummary": { - "type": "object", - "properties": { + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "V2Workspace": { + "type": "object", + "properties": { "id": { "type": "string", - "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." }, "name": { "type": "string", - "description": "Kebab-case name that agents use to reference the skill." + "description": "Workspace display name." }, - "description": { + "color": { "type": "string", - "description": "One-line summary of when the skill applies." + "description": "Workspace color as a hexadecimal color value." }, - "readOnly": { - "type": "boolean", - "description": "Whether this is a built-in skill that cannot be modified or deleted." + "logoUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workspace logo URL, or null when none is configured." + }, + "memberCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of effective members, including inherited organization administrators." }, "createdAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + "description": "ISO 8601 timestamp when the workspace was created." }, "updatedAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + "description": "ISO 8601 timestamp when the workspace was last updated." } }, - "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], + "required": ["id", "name", "color", "logoUrl", "memberCount", "createdAt", "updatedAt"], "additionalProperties": false, - "title": "Skill summary", - "description": "Public summary metadata for a workspace or built-in skill." + "title": "Workspace", + "description": "Public metadata for an accessible workspace." }, - "ListSkillsResponse": { + "GetWorkspaceResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Workspace" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get workspace response", + "description": "Public metadata for one workspace.", + "examples": [ + { + "data": { + "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Engineering", + "color": "#33C482", + "logoUrl": null, + "memberCount": 14, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "V2WorkspaceMember": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "description": "Member email address and public member identifier." + }, + "name": { + "type": "string", + "description": "Member display name." + }, + "image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Member profile image URL, or null when absent." + }, + "role": { + "type": "string", + "enum": ["admin", "write", "read"], + "description": "Effective role in the workspace." + }, + "isExternal": { + "type": "boolean", + "description": "Whether the member belongs to a different organization than the workspace. True only for an explicitly granted member whose own organization differs; inherited organization-administrator access is always reported as false, so this does not detect every outside caller." + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when access was granted." + } + }, + "required": ["email", "name", "image", "role", "isExternal", "joinedAt"], + "additionalProperties": false, + "title": "Workspace member", + "description": "An effective workspace member and their public access role." + }, + "ListWorkspaceMembersResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2SkillSummary" + "$ref": "#/components/schemas/V2WorkspaceMember" }, "description": "Items in the current page." }, @@ -3497,369 +3389,721 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List skills response", - "description": "Skill summaries available in the workspace.", + "title": "List workspace members response", + "description": "A cursor-paginated page of effective workspace members.", "examples": [ { "data": [ { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "role": "admin", + "isExternal": false, + "joinedAt": "2026-01-15T10:30:00.000Z" } ], "nextCursor": null } ] }, - "V2Skill": { + "V2McpServer": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + "description": "Unique server identifier derived from the workspace and endpoint URL." }, "name": { "type": "string", - "description": "Kebab-case name that agents use to reference the skill." + "description": "Server display name." }, "description": { - "type": "string", - "description": "One-line summary of when the skill applies." + "description": "Optional server description.", + "type": "string" }, - "readOnly": { + "transport": { + "default": "streamable-http", + "description": "Transport used to communicate with the server.", + "type": "string", + "enum": ["streamable-http"] + }, + "authType": { + "description": "Authentication method used by the server.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "url": { + "description": "Server endpoint URL.", + "type": "string" + }, + "timeout": { + "description": "Per-request timeout in milliseconds.", + "type": "number" + }, + "retries": { + "description": "Number of retries attempted per request.", + "type": "number" + }, + "enabled": { "type": "boolean", - "description": "Whether this is a built-in skill that cannot be modified or deleted." + "description": "Whether the server tools are available to workflows." + }, + "connectionStatus": { + "description": "Result of the most recent connection attempt. Registration and re-registration establish no connection — the auth-type probe they may send does not count as one — so a server begins, and returns to, `disconnected` until a tool discovery runs.", + "type": "string", + "enum": ["connected", "disconnected", "error"] + }, + "lastError": { + "description": "Message from the most recent failed connection, or null when absent. A re-registration clears it, since the configuration it described no longer applies.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolCount": { + "description": "Number of tools discovered on the server.", + "type": "number" + }, + "lastToolsRefresh": { + "description": "ISO 8601 timestamp of the most recent tool-list refresh.", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "lastConnected": { + "description": "ISO 8601 timestamp of the most recent successful connection. Absent until the server completes one; registering a server does not set it.", + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, "createdAt": { + "description": "ISO 8601 timestamp when the server was registered.", "type": "string", "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, "updatedAt": { + "description": "ISO 8601 timestamp when the server was last updated.", "type": "string", "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" }, - "content": { - "type": "string", - "description": "Skill body containing the instructions given to the agent." + "oauthClientId": { + "description": "Pre-registered OAuth client identifier, when configured.", + "type": "string" + }, + "hasHeaders": { + "type": "boolean", + "description": "Whether any request headers are configured." + }, + "headerNames": { + "type": "array", + "items": { + "type": "string", + "description": "Configured header name." + }, + "description": "Names of configured request headers. Header values are never returned." + }, + "hasOauthClientSecret": { + "type": "boolean", + "description": "Whether an OAuth client secret is stored. The value is never returned." } }, - "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt", "content"], + "required": [ + "id", + "name", + "transport", + "enabled", + "createdAt", + "updatedAt", + "hasHeaders", + "headerNames", + "hasOauthClientSecret" + ], "additionalProperties": false, - "title": "Skill", - "description": "A workspace or built-in skill including its instruction body." + "title": "MCP server", + "description": "Public MCP server configuration without write-only credential values." }, - "CreateSkillResponse": { + "ListMcpServersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2McpServer" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List MCP servers response", + "description": "MCP servers registered in the workspace.", + "examples": [ + { + "data": [ + { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + ], + "nextCursor": null + } + ] + }, + "CreateMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Create skill response", - "description": "The created skill including its content.", + "title": "Create MCP server response", + "description": "The registered MCP server without write-only credentials.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "disconnected", + "lastError": null, + "toolCount": 0, "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } } ] }, - "CreateSkillRequest": { + "CreateMcpServerRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the skill." + "description": "Workspace in which to register the server." }, "name": { "type": "string", "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", - "description": "Kebab-case name, unique within the workspace and not reserved by a built-in skill." + "maxLength": 255, + "description": "Server display name." }, "description": { + "description": "Optional server description.", "type": "string", - "minLength": 1, - "maxLength": 1024, - "description": "One-line summary of when the skill applies." + "maxLength": 2000 }, - "content": { + "transport": { + "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", + "default": "streamable-http", + "type": "string", + "enum": ["streamable-http"] + }, + "url": { "type": "string", "minLength": 1, - "maxLength": 50000, - "description": "Skill body containing the instructions given to the agent." - } - }, - "required": ["workspaceId", "name", "description", "content"], - "additionalProperties": false, - "title": "Create skill request", - "description": "Definition of a new skill.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "refund-policy", - "description": "How support should handle refund requests", - "content": "# Refund policy\n\nAlways check the order date first." - } - ] - }, - "GetSkillResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get skill response", - "description": "One skill including its full content.", - "examples": [ - { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "How support should handle refund requests", - "readOnly": false, - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." - } - } - ] - }, - "UpdateSkillResponse": { + "maxLength": 2048, + "description": "Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints." + }, + "authType": { + "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "headers": { + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", + "writeOnly": true, + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "description": "Header value sent to the MCP server." + } + }, + "timeout": { + "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", + "default": 30000, + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", + "default": 3, + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", + "default": true, + "type": "boolean" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ] + }, + "oauthClientSecret": { + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", + "writeOnly": true, + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] + } + }, + "required": ["workspaceId", "name", "url"], + "additionalProperties": false, + "title": "Create MCP server request", + "description": "Configuration for a new MCP server.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Docs server", + "url": "https://mcp.example.com/sse", + "authType": "headers", + "headers": { + "Authorization": "Bearer YOUR_TOKEN" + } + } + ] + }, + "GetMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2Skill" + "$ref": "#/components/schemas/V2McpServer" } }, "required": ["data"], "additionalProperties": false, - "title": "Update skill response", - "description": "The updated skill including its full content.", + "title": "Get MCP server response", + "description": "One MCP server without write-only credentials.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "name": "refund-policy", - "description": "Updated refund guidance", - "readOnly": false, + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": true, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z", - "content": "# Refund policy\n\nAlways check the order date first." + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false } } ] }, - "UpdateSkillRequest": { + "UpdateMcpServerResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2McpServer" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update MCP server response", + "description": "The updated MCP server.", + "examples": [ + { + "data": { + "id": "mcp-3f7a9c21", + "name": "Docs server", + "description": "Internal documentation tools", + "transport": "streamable-http", + "authType": "headers", + "url": "https://mcp.example.com/sse", + "timeout": 30000, + "retries": 3, + "enabled": false, + "connectionStatus": "connected", + "lastError": null, + "toolCount": 7, + "lastToolsRefresh": "2026-06-20T14:02:11.000Z", + "lastConnected": "2026-06-20T14:02:11.000Z", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "hasHeaders": true, + "headerNames": ["Authorization"], + "hasOauthClientSecret": false + } + } + ] + }, + "UpdateMcpServerRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace that owns the skill." + "description": "Workspace that owns the MCP server." }, "name": { - "description": "New kebab-case skill name.", "type": "string", "minLength": 1, - "maxLength": 64, - "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + "maxLength": 255, + "description": "Server display name." }, "description": { - "description": "New one-line summary of when the skill applies.", + "description": "Optional server description.", "type": "string", - "minLength": 1, - "maxLength": 1024 + "maxLength": 2000 }, - "content": { - "description": "Replacement skill body.", + "transport": { + "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", + "default": "streamable-http", + "type": "string", + "enum": ["streamable-http"] + }, + "url": { + "description": "Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints.", "type": "string", "minLength": 1, - "maxLength": 50000 + "maxLength": 2048 + }, + "authType": { + "description": "Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method.", + "type": "string", + "enum": ["none", "headers", "oauth"] + }, + "headers": { + "description": "Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat.", + "writeOnly": true, + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1 + }, + "additionalProperties": { + "type": "string", + "description": "Header value sent to the MCP server." + } + }, + "timeout": { + "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", + "default": 30000, + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "retries": { + "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", + "default": 3, + "type": "integer", + "minimum": 0, + "maximum": 10 + }, + "enabled": { + "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", + "default": true, + "type": "boolean" + }, + "oauthClientId": { + "description": "Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization.", + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ] + }, + "oauthClientSecret": { + "description": "Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication.", + "writeOnly": true, + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] } }, "required": ["workspaceId"], "additionalProperties": false, - "title": "Update skill request", - "description": "Skill fields to change; at least one editable field is required.", + "title": "Update MCP server request", + "description": "MCP server fields to change; omitted fields retain their stored values.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "description": "Updated refund guidance" + "enabled": false } ] }, - "V2SkillDeleteData": { + "V2McpServerDeleteData": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the deleted skill." + "description": "Identifier of the deleted MCP server." }, "deleted": { "type": "boolean", "const": true, - "description": "Whether the skill was deleted." + "description": "Whether the server was deleted." } }, "required": ["id", "deleted"], "additionalProperties": false, - "title": "Delete skill data", - "description": "Skill deletion acknowledgement." + "title": "Delete MCP server data", + "description": "MCP server deletion acknowledgement." }, - "DeleteSkillResponse": { + "DeleteMcpServerResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2SkillDeleteData" + "$ref": "#/components/schemas/V2McpServerDeleteData" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete skill response", - "description": "Acknowledgement that the skill was deleted.", + "title": "Delete MCP server response", + "description": "Acknowledgement that the MCP server was deleted.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", + "id": "mcp-3f7a9c21", "deleted": true } } ] }, - "V2CustomTool": { + "V2McpTool": { "type": "object", "properties": { - "id": { + "name": { "type": "string", - "description": "Unique custom tool identifier." + "description": "Tool name, as the MCP server reports it." }, - "title": { - "type": "string", - "description": "Display title, unique within the workspace." + "description": { + "description": "Tool description reported by the server.", + "type": "string" }, - "schema": { + "inputSchema": { "type": "object", "properties": { "type": { "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "const": "object", + "description": "JSON Schema type of the argument object. MCP requires `object`." }, - "function": { + "properties": { + "description": "Argument schemas keyed by argument name.", "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." - }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } + "propertyNames": { + "type": "string" }, - "required": ["name", "parameters"], "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function definition." + "description": "Server-defined JSON Schema for one tool argument." + } + }, + "required": { + "description": "Names of the arguments the tool requires.", + "type": "array", + "items": { + "type": "string", + "description": "Name of a required argument." + } } }, - "required": ["type", "function"], + "required": ["type"], "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." + "description": "Additional JSON Schema keyword reported by the server." }, - "description": "OpenAI-style function declaration describing the callable tool surface." + "description": "JSON Schema for the tool's arguments, as reported by the server." }, - "code": { + "serverId": { "type": "string", - "description": "Tool implementation executed in the sandboxed function runtime." + "description": "Identifier of the MCP server exposing the tool." + }, + "serverName": { + "type": "string", + "description": "Display name of the MCP server exposing the tool." + } + }, + "required": ["name", "inputSchema", "serverId", "serverName"], + "additionalProperties": false, + "title": "MCP tool", + "description": "A tool exposed by a registered MCP server." + }, + "ListMcpServerToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2McpTool" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List MCP server tools response", + "description": "Tools exposed by the MCP server.", + "examples": [ + { + "data": [ + { + "name": "search_docs", + "description": "Search the internal documentation", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search terms" + } + }, + "required": ["query"] + }, + "serverId": "mcp-3f7a9c21", + "serverName": "Docs server" + } + ], + "nextCursor": null + } + ] + }, + "V2SkillSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + }, + "name": { + "type": "string", + "description": "Kebab-case name that agents use to reference the skill." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "Whether this is a built-in skill that cannot be modified or deleted." }, "createdAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the tool was created." + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." }, "updatedAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the tool was last updated." + "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." } }, - "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt"], "additionalProperties": false, - "title": "Custom tool", - "description": "A workspace custom tool and its callable function declaration." + "title": "Skill summary", + "description": "Public summary metadata for a workspace or built-in skill." }, - "ListCustomToolsResponse": { + "ListSkillsResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2CustomTool" + "$ref": "#/components/schemas/V2SkillSummary" }, "description": "Items in the current page." }, @@ -3877,31 +4121,16 @@ }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List custom tools response", - "description": "Custom tools defined in the workspace.", + "title": "List skills response", + "description": "Skill summaries available in the workspace.", "examples": [ { "data": [ { "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" } @@ -3910,59 +4139,255 @@ } ] }, - "CreateCustomToolResponse": { + "V2Skill": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + "id": { + "type": "string", + "description": "Unique skill identifier. A built-in skill is `builtin-` followed by its name, for example `builtin-research`." + }, + "name": { + "type": "string", + "description": "Kebab-case name that agents use to reference the skill." + }, + "description": { + "type": "string", + "description": "One-line summary of when the skill applies." + }, + "readOnly": { + "type": "boolean", + "description": "Whether this is a built-in skill that cannot be modified or deleted." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was created. Built-in skills report the Unix epoch." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the skill was last updated. Built-in skills report the Unix epoch." + }, + "content": { + "type": "string", + "description": "Skill body containing the instructions given to the agent." } }, - "required": ["data"], + "required": ["id", "name", "description", "readOnly", "createdAt", "updatedAt", "content"], "additionalProperties": false, - "title": "Create custom tool response", - "description": "The created custom tool.", - "examples": [ - { - "data": { + "title": "Skill", + "description": "A workspace or built-in skill including its instruction body." + }, + "CreateSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Skill" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create skill response", + "description": "The created skill including its content.", + "examples": [ + { + "data": { "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." } } ] }, - "CreateCustomToolRequest": { + "CreateSkillRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the custom tool." + "description": "Workspace in which to create the skill." }, - "title": { + "name": { "type": "string", "minLength": 1, - "maxLength": 200, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$", + "description": "Kebab-case name, unique within the workspace and not reserved by a built-in skill." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "One-line summary of when the skill applies." + }, + "content": { + "type": "string", + "minLength": 1, + "maxLength": 50000, + "description": "Skill body containing the instructions given to the agent." + } + }, + "required": ["workspaceId", "name", "description", "content"], + "additionalProperties": false, + "title": "Create skill request", + "description": "Definition of a new skill.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "refund-policy", + "description": "How support should handle refund requests", + "content": "# Refund policy\n\nAlways check the order date first." + } + ] + }, + "GetSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Skill" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get skill response", + "description": "One skill including its full content.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "How support should handle refund requests", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + ] + }, + "UpdateSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Skill" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update skill response", + "description": "The updated skill including its full content.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "name": "refund-policy", + "description": "Updated refund guidance", + "readOnly": false, + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z", + "content": "# Refund policy\n\nAlways check the order date first." + } + } + ] + }, + "UpdateSkillRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the skill." + }, + "name": { + "description": "New kebab-case skill name.", + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" + }, + "description": { + "description": "New one-line summary of when the skill applies.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "content": { + "description": "Replacement skill body.", + "type": "string", + "minLength": 1, + "maxLength": 50000 + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Update skill request", + "description": "Skill fields to change; at least one editable field is required.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "description": "Updated refund guidance" + } + ] + }, + "V2SkillDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted skill." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the skill was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete skill data", + "description": "Skill deletion acknowledgement." + }, + "DeleteSkillResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SkillDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete skill response", + "description": "Acknowledgement that the skill was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } + } + ] + }, + "V2CustomTool": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique custom tool identifier." + }, + "title": { + "type": "string", "description": "Display title, unique within the workspace." }, "schema": { @@ -4032,34 +4457,233 @@ }, "code": { "type": "string", - "maxLength": 100000, "description": "Tool implementation executed in the sandboxed function runtime." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the tool was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the tool was last updated." } }, - "required": ["workspaceId", "title", "schema", "code"], + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], "additionalProperties": false, - "title": "Create custom tool request", - "description": "Definition and implementation of a new custom tool.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, + "title": "Custom tool", + "description": "A workspace custom tool and its callable function declaration." + }, + "ListCustomToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CustomTool" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List custom tools response", + "description": "Custom tools defined in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create custom tool response", + "description": "The created custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateCustomToolRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the custom tool." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "Tool implementation executed in the sandboxed function runtime." + } + }, + "required": ["workspaceId", "title", "schema", "code"], + "additionalProperties": false, + "title": "Create custom tool request", + "description": "Definition and implementation of a new custom tool.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, "code": "return { ok: true }" } ] @@ -4633,471 +5257,2190 @@ "required": ["id", "label", "placeholder", "required", "secret", "multiline"], "additionalProperties": false }, - "description": "Create-body fields accepted by this provider. Secret fields are write-only." + "description": "Create-body fields accepted by this provider. Secret fields are write-only." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "providerId", + "docsUrl", + "requiresClientGeneratedCredentialId", + "fields" + ], + "additionalProperties": false + } + ], + "title": "Credential Provider", + "description": "An OAuth or service-account connection method available to a workspace." + }, + "ListCredentialProvidersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CredentialProvider" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List credential providers response", + "description": "OAuth and service-account connection methods.", + "examples": [ + { + "data": [ + { + "type": "oauth", + "serviceId": "salesforce", + "name": "Salesforce", + "description": "Connect to Salesforce CRM data and operations.", + "providerFamily": "salesforce", + "available": true, + "supportsReconnect": true, + "authorizationOptions": [ + { + "providerId": "salesforce", + "label": "Production" + }, + { + "providerId": "salesforce-sandbox", + "label": "Sandbox" + } + ] + }, + { + "type": "service_account", + "serviceId": "zoom-service-account", + "providerId": "zoom-service-account", + "name": "Zoom server-to-server app", + "description": "Connect Zoom with a server-to-server app.", + "providerFamily": "zoom", + "available": true, + "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", + "requiresClientGeneratedCredentialId": false, + "fields": [ + { + "id": "clientId", + "label": "Client ID", + "placeholder": "Paste the client ID", + "required": true, + "secret": false, + "multiline": false + }, + { + "id": "clientSecret", + "label": "Client secret", + "placeholder": "Paste the client secret", + "required": true, + "secret": true, + "multiline": false + }, + { + "id": "orgId", + "label": "Account ID", + "placeholder": "Paste the account ID", + "required": true, + "secret": false, + "multiline": false + } + ] + } + ], + "nextCursor": null + } + ] + }, + "CreateServiceAccountCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Credential" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateServiceAccountCredentialRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "type": { + "type": "string", + "const": "service_account", + "description": "Service-account credential discriminator." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID returned by provider discovery." + }, + "displayName": { + "description": "Optional name; providers may derive one from the verified account identity.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "Optional credential description.", + "type": "string", + "maxLength": 500 + }, + "id": { + "description": "Required only when provider discovery requests a client-generated ID.", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 + }, + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "domain": { + "description": "Provider account domain.", + "type": "string", + "minLength": 1, + "maxLength": 2048 + }, + "signingSecret": { + "description": "Write-only webhook signing secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "botToken": { + "description": "Write-only bot token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "clientId": { + "description": "OAuth client identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "description": "Write-only OAuth client secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "certificateId": { + "description": "Provider certificate mapping identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "orgId": { + "description": "Provider organization ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "description": "Provider data center.", + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "authMethod": { + "description": "Provider authentication method.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "privateKey": { + "description": "Write-only PEM private key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "username": { + "description": "Provider run-as username.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": ["workspaceId", "type", "providerId"], + "additionalProperties": false, + "title": "Create service-account credential request", + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "displayName": "Zoom automation", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + ] + }, + "V2CredentialConnectionAuthorization": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the connection link expires." + } + }, + "required": ["authorizationUrl", "expiresAt"], + "additionalProperties": false, + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." + }, + "CreateCredentialConnectionResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create credential connection response", + "description": "Short-lived Sim browser entrypoint and its expiry.", + "examples": [ + { + "data": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "expiresAt": "2026-06-20T14:17:11.000Z" + } + } + ] + }, + "CreateCredentialConnectionBody": { + "anyOf": [ + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider ID returned by credential-provider discovery." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + } + }, + "required": ["workspaceId", "providerId", "displayName"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." + }, + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Existing OAuth credential to reconnect in place." + } + }, + "required": ["workspaceId", "credentialId"], + "additionalProperties": false + } + ], + "title": "Create credential connection body", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." + }, + "V2CredentialDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Disconnected credential identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the credential was disconnected." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete credential data", + "description": "Credential disconnection acknowledgement." + }, + "DeleteCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Disconnect credential response", + "description": "Acknowledgement that the credential was disconnected.", + "examples": [ + { + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } + } + ] + }, + "V2Secret": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the secret." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was last updated." + } + }, + "required": ["name", "scope", "description", "role", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Secret metadata", + "description": "Public secret metadata without the stored secret value." + }, + "ListSecretsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Secret" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List secrets response", + "description": "Secret metadata visible to the caller without stored values.", + "examples": [ + { + "data": [ + { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "description": "Production billing key — rotate quarterly.", + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "SetSecretResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Secret" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Set secret response", + "description": "Metadata for the created or replaced secret without its value.", + "examples": [ + { + "data": { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "description": "Production billing key — rotate quarterly.", + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "SetSecretRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace the request is authorized against. A workspace secret is written to it; a personal secret is written to the caller and is available in all of their workspaces." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "value": { + "type": "string", + "minLength": 1, + "maxLength": 65536, + "description": "Write-only secret value. It is never returned.", + "writeOnly": true + }, + "description": { + "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", + "anyOf": [ + { + "type": "string", + "maxLength": 500 + }, + { + "type": "null" + } + ] + } + }, + "required": ["workspaceId", "scope", "value"], + "additionalProperties": false, + "title": "Set secret request", + "description": "Ownership scope and write-only value for the secret.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "value": "YOUR_SECRET_VALUE" + } + ] + }, + "V2SecretDeleteData": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the secret was deleted." + } + }, + "required": ["name", "scope", "deleted"], + "additionalProperties": false, + "title": "Delete secret data", + "description": "Secret deletion acknowledgement without the stored value." + }, + "DeleteSecretResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2SecretDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete secret response", + "description": "Acknowledgement that the secret was deleted.", + "examples": [ + { + "data": { + "name": "STRIPE_API_KEY", + "scope": "workspace", + "deleted": true + } + } + ] + }, + "V2BlockSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Block type identifier, used as a workflow block’s `type`." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "One-line summary of what the block does." + }, + "longDescription": { + "description": "Extended explanation, when the block has one.", + "type": "string" + }, + "category": { + "type": "string", + "description": "Toolbar category: `blocks`, `tools`, or `triggers`." + }, + "integrationType": { + "description": "Integration category, e.g. `communication`, `databases`.", + "type": "string" + }, + "source": { + "type": "string", + "enum": ["builtin", "custom"], + "description": "Where the block comes from: `builtin` is the shipped registry, `custom` is a workflow this workspace deployed as a block." + }, + "authMode": { + "description": "How the block authenticates: `oauth`, `api_key`, or `bot_token`.", + "type": "string" + }, + "triggerAllowed": { + "type": "boolean", + "description": "Whether the block declares itself usable as a trigger." + }, + "triggerCapable": { + "type": "boolean", + "description": "Whether the block can start a workflow — a trigger-category block, one declaring `triggerAllowed`, or one with trigger-mode fields." + }, + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of the triggers this block supports." + }, + "toolIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + }, + "operationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + }, + "preview": { + "type": "boolean", + "description": "Whether the block is unreleased and revealed only to this caller." + }, + "sunset": { + "description": "Post-release lifecycle state. Absent for a block in normal support.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["legacy", "deprecated"], + "description": "`legacy` is superseded but supported; `deprecated` is slated for removal." + }, + "replacedBy": { + "description": "Block type to migrate to, when one exists.", + "type": "string" + } + }, + "required": ["status"], + "additionalProperties": false + }, + "docsLink": { + "description": "Sim documentation page for the integration.", + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Catalog tags, e.g. `messaging`, `version-control`." + } + }, + "required": [ + "id", + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags" + ], + "additionalProperties": false, + "title": "Block summary", + "description": "List view of a block: what it is and what it references, by id." + }, + "ListBlocksResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockSummary" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List blocks response", + "description": "Blocks available in the workspace.", + "examples": [ + { + "data": [ + { + "id": "slack", + "name": "Slack", + "description": "Send messages and read channels in Slack.", + "category": "tools", + "integrationType": "communication", + "source": "builtin", + "authMode": "oauth", + "triggerAllowed": true, + "triggerCapable": true, + "triggerIds": ["slack_webhook"], + "toolIds": ["slack_message", "slack_canvas_read"], + "operationIds": ["send", "read"], + "preview": false, + "docsLink": "https://docs.sim.ai/tools/slack", + "tags": ["messaging"] + } + ], + "nextCursor": null + } + ] + }, + "V2BlockField": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Field identifier, and the key its value is stored under." + }, + "type": { + "type": "string", + "description": "Editor control the field renders as, e.g. `short-input`." + }, + "title": { + "description": "Human-readable label.", + "type": "string" + }, + "required": { + "description": "Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.", + "type": "boolean" + }, + "requiredWhen": { + "description": "Condition under which the field is required.", + "$ref": "#/components/schemas/V2CatalogCondition" + }, + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "mode": { + "description": "Where the field renders: `basic`, `advanced`, `both`, `trigger`, or `trigger-advanced`.", + "type": "string" + }, + "hidden": { + "description": "Whether the field is hidden in the editor.", + "type": "boolean" + }, + "condition": { + "description": "Condition under which the field applies at all.", + "$ref": "#/components/schemas/V2CatalogCondition" + }, + "options": { + "description": "Selectable options. Absent on fields whose options are fetched per workspace at edit time.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "description": "Human-readable option label.", + "type": "string" + }, + "hasIcon": { + "description": "Whether the option renders with an icon. The icon itself is not published.", + "type": "boolean" + } + }, + "required": ["id"], + "additionalProperties": false + } + }, + "min": { + "description": "Minimum accepted numeric value.", + "type": "number" + }, + "max": { + "description": "Maximum accepted numeric value.", + "type": "number" + }, + "step": { + "description": "Increment for numeric controls.", + "type": "number" + }, + "integer": { + "description": "Whether the numeric value must be a whole number.", + "type": "boolean" + }, + "rows": { + "description": "Visible row count for multi-line text.", + "type": "number" + }, + "password": { + "description": "Whether the stored value is masked in the editor.", + "type": "boolean" + }, + "multiSelect": { + "description": "Whether more than one option may be selected.", + "type": "boolean" + }, + "language": { + "description": "Language of a code field.", + "type": "string" + }, + "generationType": { + "description": "Kind of content AI assistance generates here.", + "type": "string" + }, + "serviceId": { + "description": "OAuth service this credential field authenticates.", + "type": "string" + }, + "requiredScopes": { + "description": "OAuth scopes the credential selected here must carry.", + "type": "array", + "items": { + "type": "string" + } + }, + "mimeType": { + "description": "MIME type filter applied to a file picker.", + "type": "string" + }, + "acceptedTypes": { + "description": "Accepted file extensions for an upload field.", + "type": "string" + }, + "multiple": { + "description": "Whether more than one file may be supplied.", + "type": "boolean" + }, + "maxSize": { + "description": "Maximum upload size in megabytes.", + "type": "number" + }, + "connectionDroppable": { + "description": "Whether another block’s output can be dropped onto this field.", + "type": "boolean" + }, + "columns": { + "description": "Column headings for a table field.", + "type": "array", + "items": { + "type": "string" + } + }, + "dependsOn": { + "description": "Sibling fields this field is cleared by when they change.", + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object", + "properties": { + "all": { + "description": "Every listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + }, + "any": { + "description": "At least one listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + ] + }, + "canonicalParamId": { + "description": "Shared key for a picker/manual-entry pair. Both fields write the same value, so supply exactly one of the pair.", + "type": "string" + }, + "defaultValue": { + "description": "Value used when the field is left unset.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Member of an object-valued default. Shape varies by field type." + } + }, + { + "type": "array", + "items": { + "description": "Element of an array-valued default. Shape varies by field type." + } } - }, - "required": [ - "type", - "serviceId", - "name", - "description", - "providerFamily", - "available", - "providerId", - "docsUrl", - "requiresClientGeneratedCredentialId", - "fields" - ], - "additionalProperties": false + ] + }, + "hasComputedDefault": { + "description": "Whether the field derives its value from the block’s other values. The deriving function is not published.", + "type": "boolean" } - ], - "title": "Credential Provider", - "description": "An OAuth or service-account connection method available to a workspace." + }, + "required": ["id", "type"], + "additionalProperties": false, + "title": "Block field", + "description": "One configuration field on a block." }, - "ListCredentialProvidersResponse": { + "V2CatalogCondition": { "type": "object", "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2CredentialProvider" - }, - "description": "Items in the current page." + "field": { + "type": "string", + "description": "Sibling field id whose value decides this condition." }, - "nextCursor": { + "value": { "anyOf": [ { "type": "string" }, { - "type": "null" + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } } ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "List credential providers response", - "description": "OAuth and service-account connection methods.", - "examples": [ - { - "data": [ - { - "type": "oauth", - "serviceId": "salesforce", - "name": "Salesforce", - "description": "Connect to Salesforce CRM data and operations.", - "providerFamily": "salesforce", - "available": true, - "supportsReconnect": true, - "authorizationOptions": [ + "description": "Value, or set of accepted values, the named field must hold." + }, + "not": { + "description": "Invert the match: every value EXCEPT `value`.", + "type": "boolean" + }, + "and": { + "description": "A second clause that must hold as well.", + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "Sibling field id for the second clause." + }, + "value": { + "description": "Value the second clause matches. Absent means \"holds any value\".", + "anyOf": [ { - "providerId": "salesforce", - "label": "Production" + "type": "string" }, { - "providerId": "salesforce-sandbox", - "label": "Sandbox" - } - ] - }, - { - "type": "service_account", - "serviceId": "zoom-service-account", - "providerId": "zoom-service-account", - "name": "Zoom server-to-server app", - "description": "Connect Zoom with a server-to-server app.", - "providerFamily": "zoom", - "available": true, - "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", - "requiresClientGeneratedCredentialId": false, - "fields": [ - { - "id": "clientId", - "label": "Client ID", - "placeholder": "Paste the client ID", - "required": true, - "secret": false, - "multiline": false + "type": "number" }, { - "id": "clientSecret", - "label": "Client secret", - "placeholder": "Paste the client secret", - "required": true, - "secret": true, - "multiline": false + "type": "boolean" }, { - "id": "orgId", - "label": "Account ID", - "placeholder": "Paste the account ID", - "required": true, - "secret": false, - "multiline": false + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } } ] + }, + "not": { + "description": "Invert the second clause.", + "type": "boolean" } - ], - "nextCursor": null + }, + "required": ["field"], + "additionalProperties": false } - ] + }, + "required": ["field", "value"], + "additionalProperties": false, + "title": "Catalog condition", + "description": "When a configuration field applies, expressed against a sibling field." }, - "CreateServiceAccountCredentialResponse": { + "V2OperationInput": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Credential" + "type": { + "type": "string", + "description": "Value type." + }, + "required": { + "description": "Whether the value must be supplied.", + "type": "boolean" + }, + "visibility": { + "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", + "type": "string" + }, + "description": { + "description": "What the value means.", + "type": "string" + }, + "default": { + "description": "Value used when this input is omitted." + }, + "items": { + "description": "JSON-Schema-shaped constraints declared by the tool parameter." + }, + "schema": { + "description": "JSON-Schema-shaped structure declared by the block input." } }, - "required": ["data"], + "required": ["type"], "additionalProperties": false, - "title": "Create service-account credential response", - "description": "Verified credential metadata without secret material.", - "examples": [ - { - "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "title": "Operation input", + "description": "One value a block operation needs, from its tool or its block-level inputs." + }, + "V2ToolOutput": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type of the output field." + }, + "description": { + "description": "What the field holds.", + "type": "string" + }, + "optional": { + "description": "Whether the field may be absent.", + "type": "boolean" + }, + "nullable": { + "description": "Whether the field may be null.", + "type": "boolean" + }, + "properties": { + "description": "Members of an object-typed output, keyed by field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Nested output field, in this same shape." } + }, + "items": { + "description": "Element shape of an array-typed output.", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Element value type." + }, + "description": { + "description": "What an element holds.", + "type": "string" + }, + "properties": { + "description": "Members of an object-typed element, keyed by field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Nested output field, in this same shape." + } + } + }, + "required": ["type"], + "additionalProperties": false + }, + "fileConfig": { + "description": "File metadata for a file-typed output.", + "type": "object", + "properties": { + "mimeType": { + "description": "MIME type of the produced file.", + "type": "string" + }, + "extension": { + "description": "File extension of the produced file.", + "type": "string" + } + }, + "additionalProperties": false } - ] + }, + "required": ["type"], + "additionalProperties": false, + "title": "Tool output", + "description": "One declared output field of a built-in tool." + }, + "V2ToolDetail": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Registered tool identifier, including its version suffix." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "What the tool does." + }, + "version": { + "description": "Tool version.", + "type": "string" + }, + "hostedApiKey": { + "type": "string", + "enum": ["always", "conditional", "none"], + "description": "Whether Sim supplies the API key: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own)." + }, + "oauth": { + "description": "OAuth requirement, when the tool has one.", + "type": "object", + "properties": { + "required": { + "type": "boolean", + "description": "Whether the tool cannot run without an OAuth credential." + }, + "provider": { + "type": "string", + "description": "OAuth service the credential must authenticate." + }, + "requiredScopes": { + "description": "Scopes the credential must carry.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["required", "provider"], + "additionalProperties": false + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolParam" + }, + "description": "Parameters the tool accepts." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolOutput" + }, + "description": "Fields the tool produces." + } + }, + "required": ["id", "name", "description", "hostedApiKey", "params", "outputs"], + "additionalProperties": false, + "title": "Tool", + "description": "A built-in tool with its declared parameters and outputs." }, - "CreateServiceAccountCredentialRequest": { + "V2ToolParam": { "type": "object", "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that will own the credential." - }, "type": { "type": "string", - "const": "service_account", - "description": "Service-account credential discriminator." + "description": "Parameter value type." }, - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact service-account provider ID returned by provider discovery." + "required": { + "description": "Whether the parameter must be supplied.", + "type": "boolean" }, - "displayName": { - "description": "Optional name; providers may derive one from the verified account identity.", - "type": "string", - "minLength": 1, - "maxLength": 255 + "visibility": { + "description": "Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.", + "type": "string" }, "description": { - "description": "Optional credential description.", - "type": "string", - "maxLength": 500 + "description": "What the parameter means.", + "type": "string" }, + "default": { + "description": "Value used when the parameter is omitted." + }, + "items": { + "description": "JSON-Schema-shaped constraints for structured params." + } + }, + "required": ["type"], + "additionalProperties": false, + "title": "Tool parameter", + "description": "One declared parameter of a built-in tool." + }, + "V2BlockDetail": { + "type": "object", + "properties": { "id": { - "description": "Required only when provider discovery requests a client-generated ID.", "type": "string", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + "description": "Block type identifier, used as a workflow block’s `type`." }, - "serviceAccountJson": { - "description": "Write-only Google service-account JSON key.", - "writeOnly": true, + "name": { "type": "string", - "minLength": 1, - "maxLength": 65536 + "description": "Display name." }, - "apiToken": { - "description": "Write-only provider API token.", - "writeOnly": true, + "description": { "type": "string", - "minLength": 1, - "maxLength": 8192 + "description": "One-line summary of what the block does." }, - "domain": { - "description": "Provider account domain.", - "type": "string", - "minLength": 1, - "maxLength": 2048 + "longDescription": { + "description": "Extended explanation, when the block has one.", + "type": "string" }, - "signingSecret": { - "description": "Write-only webhook signing secret.", - "writeOnly": true, + "category": { "type": "string", - "minLength": 1, - "maxLength": 8192 + "description": "Toolbar category: `blocks`, `tools`, or `triggers`." }, - "botToken": { - "description": "Write-only bot token.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 + "integrationType": { + "description": "Integration category, e.g. `communication`, `databases`.", + "type": "string" }, - "clientId": { - "description": "OAuth client identifier.", + "source": { "type": "string", - "minLength": 1, - "maxLength": 512 + "enum": ["builtin", "custom"], + "description": "Where the block comes from: `builtin` is the shipped registry, `custom` is a workflow this workspace deployed as a block." }, - "clientSecret": { - "description": "Write-only OAuth client secret.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 1024 + "authMode": { + "description": "How the block authenticates: `oauth`, `api_key`, or `bot_token`.", + "type": "string" }, - "certificateId": { - "description": "Provider certificate mapping identifier.", - "type": "string", - "minLength": 1, - "maxLength": 512 + "triggerAllowed": { + "type": "boolean", + "description": "Whether the block declares itself usable as a trigger." }, - "orgId": { - "description": "Provider organization ID.", - "type": "string", - "minLength": 1, - "maxLength": 255 + "triggerCapable": { + "type": "boolean", + "description": "Whether the block can start a workflow — a trigger-category block, one declaring `triggerAllowed`, or one with trigger-mode fields." }, - "dataCenter": { - "description": "Provider data center.", - "type": "string", - "minLength": 1, - "maxLength": 32 + "triggerIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of the triggers this block supports." }, - "authMethod": { - "description": "Provider authentication method.", - "type": "string", - "minLength": 1, - "maxLength": 64 + "toolIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." }, - "privateKey": { - "description": "Write-only PEM private key.", - "writeOnly": true, - "type": "string", - "minLength": 1, - "maxLength": 8192 + "operationIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." }, - "username": { - "description": "Provider run-as username.", - "type": "string", - "minLength": 1, - "maxLength": 255 - } - }, - "required": ["workspaceId", "type", "providerId"], - "additionalProperties": false, - "title": "Create service-account credential request", - "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "type": "service_account", - "providerId": "zoom-service-account", - "displayName": "Zoom automation", - "clientId": "YOUR_CLIENT_ID", - "clientSecret": "YOUR_CLIENT_SECRET", - "orgId": "YOUR_ACCOUNT_ID" - } - ] - }, - "V2CredentialConnectionAuthorization": { - "type": "object", - "properties": { - "authorizationUrl": { - "type": "string", - "format": "uri", - "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + "preview": { + "type": "boolean", + "description": "Whether the block is unreleased and revealed only to this caller." + }, + "sunset": { + "description": "Post-release lifecycle state. Absent for a block in normal support.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["legacy", "deprecated"], + "description": "`legacy` is superseded but supported; `deprecated` is slated for removal." + }, + "replacedBy": { + "description": "Block type to migrate to, when one exists.", + "type": "string" + } + }, + "required": ["status"], + "additionalProperties": false + }, + "docsLink": { + "description": "Sim documentation page for the integration.", + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Catalog tags, e.g. `messaging`, `version-control`." + }, + "bestPractices": { + "description": "Authored guidance on using the block correctly.", + "type": "string" + }, + "inputSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + }, + "description": "Configuration fields that apply regardless of the selected operation." + }, + "operationInputSchema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + } + }, + "description": "Configuration fields keyed by the operation that reveals them." + }, + "inputDefinitions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type: `string`, `number`, `boolean`, `json`, `array`, or `file`." + }, + "description": { + "description": "What the input means.", + "type": "string" + }, + "schema": { + "description": "JSON-Schema-shaped structure for object and array inputs." + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Block-level input definitions, keyed by parameter name." + }, + "operations": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "toolId": { + "description": "Built-in tool that performs this operation.", + "type": "string" + }, + "toolName": { + "description": "Display name of that tool.", + "type": "string" + }, + "description": { + "description": "What the operation does.", + "type": "string" + }, + "inputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2OperationInput" + }, + "description": "Values this operation needs, excluding the ones the block supplies from its own block-level inputs." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/V2ToolOutput" + }, + "description": "Fields the operation produces." + }, + "inputSchema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2BlockField" + }, + "description": "Configuration fields that appear when this operation is selected." + } + }, + "required": ["inputs", "outputs", "inputSchema"], + "additionalProperties": false + }, + "description": "Operations the block exposes, keyed by operation id." + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ToolDetail" + }, + "description": "Every built-in tool the block can run, with parameters and outputs." + }, + "triggers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Trigger identifier." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type of the output." + }, + "description": { + "description": "What the output holds.", + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Top-level fields the trigger event delivers." + }, + "configFields": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Editor control the field renders as." + }, + "required": { + "type": "boolean", + "description": "Whether a value must be supplied." + }, + "title": { + "description": "Human-readable label.", + "type": "string" + }, + "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "default": { + "description": "Value used when the field is left unset." + }, + "options": { + "description": "Selectable options.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "type": "string", + "description": "Human-readable option label." + } + }, + "required": ["id", "label"], + "additionalProperties": false + } + }, + "condition": { + "description": "Condition under which the field applies.", + "$ref": "#/components/schemas/V2CatalogCondition" + } + }, + "required": ["type", "required"], + "additionalProperties": false + }, + "description": "Fields that configure the trigger, keyed by field id." + } + }, + "required": ["id", "outputs", "configFields"], + "additionalProperties": false + }, + "description": "Triggers the block can run on." }, - "expiresAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the connection link expires." + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Value type of the output." + }, + "description": { + "description": "What the output holds.", + "type": "string" + } + }, + "required": ["type"], + "additionalProperties": false + }, + "description": "Fields the block produces." } }, - "required": ["authorizationUrl", "expiresAt"], + "required": [ + "id", + "name", + "description", + "category", + "source", + "triggerAllowed", + "triggerCapable", + "triggerIds", + "toolIds", + "operationIds", + "preview", + "tags", + "inputSchema", + "operationInputSchema", + "inputDefinitions", + "operations", + "tools", + "triggers", + "outputs" + ], "additionalProperties": false, - "title": "Credential Connection Authorization", - "description": "A short-lived browser entrypoint for an OAuth connection flow." + "title": "Block", + "description": "A block with its configuration fields, operations, tools, and triggers." }, - "CreateCredentialConnectionResponse": { + "GetBlockResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + "$ref": "#/components/schemas/V2BlockDetail" } }, "required": ["data"], "additionalProperties": false, - "title": "Create credential connection response", - "description": "Short-lived Sim browser entrypoint and its expiry.", + "title": "Get block response", + "description": "One block with its fields, operations, tools, and triggers.", "examples": [ { "data": { - "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", - "expiresAt": "2026-06-20T14:17:11.000Z" + "id": "slack", + "name": "Slack", + "description": "Send messages and read channels in Slack.", + "category": "tools", + "integrationType": "communication", + "source": "builtin", + "authMode": "oauth", + "triggerAllowed": true, + "triggerCapable": true, + "triggerIds": ["slack_webhook"], + "toolIds": ["slack_message", "slack_canvas_read"], + "operationIds": ["send", "read"], + "preview": false, + "docsLink": "https://docs.sim.ai/tools/slack", + "tags": ["messaging"], + "inputSchema": [ + { + "id": "operation", + "type": "dropdown", + "title": "Operation", + "required": true, + "options": [ + { + "id": "send", + "label": "Send message" + }, + { + "id": "read", + "label": "Read messages" + } + ] + } + ], + "operationInputSchema": { + "send": [ + { + "id": "text", + "type": "long-input", + "title": "Message", + "required": true + } + ] + }, + "inputDefinitions": { + "channel": { + "type": "string", + "description": "Channel to post into." + } + }, + "operations": { + "send": { + "toolId": "slack_message", + "toolName": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "inputs": { + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + }, + "inputSchema": [ + { + "id": "text", + "type": "long-input", + "title": "Message", + "required": true + } + ] + } + }, + "tools": [ + { + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + }, + "params": { + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } + } + ], + "triggers": [ + { + "id": "slack_webhook", + "outputs": { + "text": { + "type": "string", + "description": "Message text." + } + }, + "configFields": { + "channels": { + "type": "short-input", + "required": false, + "title": "Channels" + } + } + } + ], + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } } } ] }, - "CreateCredentialConnectionBody": { - "anyOf": [ - { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that will own the credential." - }, - "providerId": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Exact OAuth provider ID returned by credential-provider discovery." - }, - "displayName": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Name shown for the new credential in Sim." - } - }, - "required": ["workspaceId", "providerId", "displayName"], - "additionalProperties": false + "V2ToolSummary": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Registered tool identifier, including its version suffix." }, - { + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "What the tool does." + }, + "version": { + "description": "Tool version.", + "type": "string" + }, + "hostedApiKey": { + "type": "string", + "enum": ["always", "conditional", "none"], + "description": "Whether Sim supplies the API key: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own)." + }, + "oauth": { + "description": "OAuth requirement, when the tool has one.", "type": "object", "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace expected to own the credential." + "required": { + "type": "boolean", + "description": "Whether the tool cannot run without an OAuth credential." }, - "credentialId": { + "provider": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Existing OAuth credential to reconnect in place." + "description": "OAuth service the credential must authenticate." + }, + "requiredScopes": { + "description": "Scopes the credential must carry.", + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["workspaceId", "credentialId"], + "required": ["required", "provider"], "additionalProperties": false } - ], - "title": "Create credential connection body", - "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." + }, + "required": ["id", "name", "description", "hostedApiKey"], + "additionalProperties": false, + "title": "Tool summary", + "description": "List view of a built-in tool: identity, auth, and key hosting." }, - "V2CredentialDeleteData": { + "ListToolsResponse": { "type": "object", "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Disconnected credential identifier." + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ToolSummary" + }, + "description": "Items in the current page." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the credential was disconnected." + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["id", "deleted"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Delete credential data", - "description": "Credential disconnection acknowledgement." + "title": "List tools response", + "description": "Built-in tools available in the workspace.", + "examples": [ + { + "data": [ + { + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + } + } + ], + "nextCursor": null + } + ] }, - "DeleteCredentialResponse": { + "GetToolResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CredentialDeleteData" + "$ref": "#/components/schemas/V2ToolDetail" } }, "required": ["data"], "additionalProperties": false, - "title": "Disconnect credential response", - "description": "Acknowledgement that the credential was disconnected.", + "title": "Get tool response", + "description": "One built-in tool with its parameters and outputs.", "examples": [ { "data": { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "deleted": true + "id": "slack_message", + "name": "Slack Send Message", + "description": "Send a message to a Slack channel.", + "version": "1.0.0", + "hostedApiKey": "none", + "oauth": { + "required": true, + "provider": "slack", + "requiredScopes": ["chat:write"] + }, + "params": { + "channel": { + "type": "string", + "required": true, + "description": "Channel ID to post into." + }, + "text": { + "type": "string", + "required": true, + "description": "Message body." + } + }, + "outputs": { + "ts": { + "type": "string", + "description": "Message timestamp." + } + } } } ] }, - "V2Secret": { + "V2ConnectorType": { + "type": "object", + "properties": { + "connectorType": { + "type": "string", + "description": "Exact identifier to send when creating a connector of this type." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "What the connector syncs." + }, + "version": { + "type": "string", + "description": "Connector version." + }, + "auth": { + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "oauth", + "description": "Authenticates with an OAuth credential." + }, + "provider": { + "type": "string", + "description": "OAuth service the credential must authenticate." + }, + "requiredScopes": { + "description": "Scopes the credential must carry.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["mode", "provider"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "apiKey", + "description": "Authenticates with a stored API key." + }, + "label": { + "description": "Label shown above the key field.", + "type": "string" + }, + "placeholder": { + "description": "Placeholder shown in the key field.", + "type": "string" + }, + "optional": { + "type": "boolean", + "description": "Whether the key may be left blank, for a source reachable without authentication." + } + }, + "required": ["mode", "optional"], + "additionalProperties": false + } + ], + "description": "How the connector authenticates against its source." + }, + "configFields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ConnectorConfigField" + }, + "description": "Fields that make up the connector’s `sourceConfig`." + }, + "supportsIncrementalSync": { + "type": "boolean", + "description": "Whether syncs after the first fetch only what changed." + }, + "tagDefinitions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Semantic tag identifier the connector populates." + }, + "displayName": { + "type": "string", + "description": "Human-readable tag name." + }, + "fieldType": { + "type": "string", + "enum": ["text", "number", "date", "boolean"], + "description": "Value type, which decides the tag slot pool it draws from." + } + }, + "required": ["id", "displayName", "fieldType"], + "additionalProperties": false + }, + "description": "Tags this connector writes onto the documents it syncs." + } + }, + "required": [ + "connectorType", + "name", + "description", + "version", + "auth", + "configFields", + "supportsIncrementalSync", + "tagDefinitions" + ], + "additionalProperties": false, + "title": "Connector type", + "description": "A knowledge-base connector type and the configuration it accepts." + }, + "V2ConnectorConfigField": { "type": "object", "properties": { - "name": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." + "description": "Field identifier." }, - "scope": { + "title": { "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "description": "Human-readable label." + }, + "type": { + "type": "string", + "enum": ["short-input", "dropdown", "selector"], + "description": "Control the field renders as. A `selector` fetches its options from the connected account." + }, + "placeholder": { + "description": "Placeholder shown in the editor.", + "type": "string" + }, + "required": { + "description": "Whether a value must be supplied.", + "type": "boolean" }, "description": { + "description": "Authored explanation of the field.", + "type": "string" + }, + "options": { + "description": "Static options, for a `dropdown` field.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Value stored when this option is selected." + }, + "label": { + "type": "string", + "description": "Human-readable option label." + } + }, + "required": ["id", "label"], + "additionalProperties": false + } + }, + "selectorKey": { + "description": "Names the picker a `selector` field renders. Its options are fetched per workspace.", + "type": "string" + }, + "mimeType": { + "description": "MIME type filter applied to the picker.", + "type": "string" + }, + "dependsOn": { + "description": "Sibling fields this field is cleared by when they change.", "anyOf": [ { - "type": "string" + "type": "array", + "items": { + "type": "string" + } }, { - "type": "null" + "type": "object", + "properties": { + "all": { + "description": "Every listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + }, + "any": { + "description": "At least one listed field must hold a value.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false } - ], - "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." + ] }, - "role": { + "mode": { + "description": "Which half of a canonical pair this field is: `basic` is the picker, `advanced` the manual entry.", "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the secret." + "enum": ["basic", "advanced"] }, - "createdAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was created." + "canonicalParamId": { + "description": "Shared `sourceConfig` key for a picker/manual-entry pair. Send exactly one of the pair, keyed by this value rather than by the field’s own `id`.", + "type": "string" }, - "updatedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the secret was last updated." + "multi": { + "description": "When true the stored `sourceConfig` value is a `string[]`, not a `string`: a `selector` renders a multi-select picker and a `short-input` accepts a comma-separated list.", + "type": "boolean" } }, - "required": ["name", "scope", "description", "role", "createdAt", "updatedAt"], + "required": ["id", "title", "type"], "additionalProperties": false, - "title": "Secret metadata", - "description": "Public secret metadata without the stored secret value." + "title": "Connector config field", + "description": "One field of a knowledge-base connector’s source configuration." }, - "ListSecretsResponse": { + "ListConnectorTypesResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Secret" + "$ref": "#/components/schemas/V2ConnectorType" }, "description": "Items in the current page." }, @@ -5110,145 +7453,229 @@ "type": "null" } ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "List secrets response", - "description": "Secret metadata visible to the caller without stored values.", + "title": "List connector types response", + "description": "Knowledge-base connector types and their configuration fields.", "examples": [ { "data": [ { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "description": "Production billing key — rotate quarterly.", - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" + "connectorType": "google_drive", + "name": "Google Drive", + "description": "Sync documents from a Google Drive folder.", + "version": "1.0.0", + "auth": { + "mode": "oauth", + "provider": "google-drive", + "requiredScopes": ["https://www.googleapis.com/auth/drive.readonly"] + }, + "configFields": [ + { + "id": "folderSelector", + "title": "Folder", + "type": "selector", + "selectorKey": "google-drive-folder", + "mimeType": "application/vnd.google-apps.folder", + "mode": "basic", + "canonicalParamId": "folderId", + "required": true + }, + { + "id": "manualFolderId", + "title": "Folder ID", + "type": "short-input", + "placeholder": "Enter the folder ID", + "mode": "advanced", + "canonicalParamId": "folderId" + } + ], + "supportsIncrementalSync": true, + "tagDefinitions": [ + { + "id": "owner", + "displayName": "Owner", + "fieldType": "text" + } + ] } ], "nextCursor": null } ] }, - "SetSecretResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2Secret" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Set secret response", - "description": "Metadata for the created or replaced secret without its value.", - "examples": [ - { - "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "description": "Production billing key — rotate quarterly.", - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] - }, - "SetSecretRequest": { + "V2Enrichment": { "type": "object", "properties": { - "workspaceId": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace the request is authorized against. A workspace secret is written to it; a personal secret is written to the caller and is available in all of their workspaces." + "description": "Enrichment identifier." }, - "scope": { + "name": { "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "description": "Display name." }, - "value": { + "description": { "type": "string", - "minLength": 1, - "maxLength": 65536, - "description": "Write-only secret value. It is never returned.", - "writeOnly": true + "description": "What the enrichment fills in." }, - "description": { - "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", - "anyOf": [ - { - "type": "string", - "maxLength": 500 + "inputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Key the value is supplied under." + }, + "name": { + "type": "string", + "description": "Human-readable label." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean"], + "description": "Value type." + }, + "required": { + "description": "Whether the input must be mapped.", + "type": "boolean" + }, + "description": { + "description": "What the input means.", + "type": "string" + } }, - { - "type": "null" - } - ] - } - }, - "required": ["workspaceId", "scope", "value"], - "additionalProperties": false, - "title": "Set secret request", - "description": "Ownership scope and write-only value for the secret.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "workspace", - "value": "YOUR_SECRET_VALUE" - } - ] - }, - "V2SecretDeleteData": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret name containing only letters, numbers, and underscores." + "required": ["id", "name", "type"], + "additionalProperties": false + }, + "description": "Per-row inputs the enrichment needs, each mapped to a table column." }, - "scope": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Key the value is returned under." + }, + "name": { + "type": "string", + "description": "Default column name." + }, + "type": { + "type": "string", + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], + "description": "Table column type the value is stored as." + } + }, + "required": ["id", "name", "type"], + "additionalProperties": false + }, + "description": "Values the enrichment produces, each becoming a table column." }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the secret was deleted." + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Provider identifier." + }, + "label": { + "type": "string", + "description": "Human-readable provider name." + }, + "toolId": { + "type": "string", + "description": "Built-in tool the provider runs. Resolve it with `GET /api/v2/tools/{toolId}`." + } + }, + "required": ["id", "label", "toolId"], + "additionalProperties": false + }, + "description": "Data sources in the order they are attempted. The first provider to return a non-empty result fills the cell, so this order is behavior rather than presentation." } }, - "required": ["name", "scope", "deleted"], + "required": ["id", "name", "description", "inputs", "outputs", "providers"], "additionalProperties": false, - "title": "Delete secret data", - "description": "Secret deletion acknowledgement without the stored value." + "title": "Enrichment", + "description": "A code-defined table enrichment and its provider cascade." }, - "DeleteSecretResponse": { + "ListEnrichmentsResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2SecretDeleteData" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Enrichment" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Delete secret response", - "description": "Acknowledgement that the secret was deleted.", + "title": "List enrichments response", + "description": "Table enrichments and their provider cascades.", "examples": [ { - "data": { - "name": "STRIPE_API_KEY", - "scope": "workspace", - "deleted": true - } + "data": [ + { + "id": "work-email", + "name": "Work email", + "description": "Find a person’s work email from their name and company.", + "inputs": [ + { + "id": "fullName", + "name": "Full name", + "type": "string", + "required": true + }, + { + "id": "domain", + "name": "Company domain", + "type": "string", + "required": true + } + ], + "outputs": [ + { + "id": "email", + "name": "Work email", + "type": "string" + } + ], + "providers": [ + { + "id": "hunter", + "label": "Hunter", + "toolId": "hunter_email_finder" + }, + { + "id": "pdl", + "label": "People Data Labs", + "toolId": "peopledatalabs_person_enrich" + } + ] + } + ], + "nextCursor": null } ] } diff --git a/apps/sim/app/api/v2/blocks/[blockId]/route.ts b/apps/sim/app/api/v2/blocks/[blockId]/route.ts new file mode 100644 index 00000000000..31236183629 --- /dev/null +++ b/apps/sim/app/api/v2/blocks/[blockId]/route.ts @@ -0,0 +1,23 @@ +import { v2GetBlockContract } from '@/lib/api/contracts/v2/catalog' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { getCatalogBlock } from '@/lib/catalog/application/get-block' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/blocks/{blockId} — Read one block's full configuration shape. */ +export const GET = defineV2JsonRoute({ + contract: v2GetBlockContract, + operation: catalogOperations.readBlock, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + blockId: params.blockId, + }), + useCase: getCatalogBlock, + present: ({ block }) => ({ data: block }), +}) diff --git a/apps/sim/app/api/v2/blocks/route.test.ts b/apps/sim/app/api/v2/blocks/route.test.ts new file mode 100644 index 00000000000..764fa491dcc --- /dev/null +++ b/apps/sim/app/api/v2/blocks/route.test.ts @@ -0,0 +1,246 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ list: vi.fn(), read: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/catalog/application/list-blocks', () => ({ + listCatalogBlocks: { operation: { id: 'catalog.blocks.list' }, execute: mocks.list }, +})) +vi.mock('@/lib/catalog/application/get-block', () => ({ + getCatalogBlock: { operation: { id: 'catalog.blocks.read' }, execute: mocks.read }, +})) + +import { v2ListBlocksContract } from '@/lib/api/contracts/v2/catalog' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET as GET_BLOCK } from '@/app/api/v2/blocks/[blockId]/route' +import { GET } from '@/app/api/v2/blocks/route' +import { cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const summary = { + id: 'slack', + name: 'Slack', + description: 'Send messages in Slack.', + category: 'tools', + source: 'builtin' as const, + triggerAllowed: true, + triggerCapable: true, + triggerIds: [], + toolIds: ['slack_message'], + operationIds: ['send'], + preview: false, + tags: ['messaging'], +} + +const detail = { + ...summary, + inputSchema: [], + operationInputSchema: {}, + inputDefinitions: {}, + operations: {}, + tools: [], + triggers: [], + outputs: {}, +} + +/** A cursor exactly as this route mints one, built from the shared codec. */ +function blockCursor({ + offset, + search, + category, + capability, + source, + sortBy = 'id', + sortOrder = 'asc', +}: { + offset: number + search?: string + category?: string + capability?: string + source?: string + sortBy?: string + sortOrder?: string +}): string { + return encodeOffsetCursor( + cursorSortKey(sortBy, sortOrder), + cursorScopeKey(cursorRoute(v2ListBlocksContract), { + workspaceId: WORKSPACE_ID, + search, + category, + capability, + source, + }), + offset + ) +} + +function request(url: string) { + return new NextRequest(`http://localhost:3000${url}`, { headers: { 'x-api-key': 'key' } }) +} + +describe('/api/v2/blocks', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.list.mockResolvedValue({ entries: [summary], hasMore: false, offset: 0, limit: 50 }) + mocks.read.mockResolvedValue({ block: detail }) + }) + + it('returns the v2 list envelope and keeps the response out of shared caches', async () => { + const response = await GET(request(`/api/v2/blocks?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(await response.json()).toEqual({ data: [summary], nextCursor: null }) + expect(mocks.list).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + search: undefined, + category: undefined, + capability: undefined, + source: undefined, + sortBy: 'id', + sortOrder: 'asc', + limit: 50, + cursor: undefined, + offset: 0, + }, + request: expect.anything(), + }) + }) + + it('resumes from the offset cursor and mints the next one while pages remain', async () => { + mocks.list.mockResolvedValue({ entries: [summary], hasMore: true, offset: 2, limit: 2 }) + const cursor = blockCursor({ offset: 2 }) + + const response = await GET( + request( + `/api/v2/blocks?workspaceId=${WORKSPACE_ID}&limit=2&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(200) + expect((await response.json()).nextCursor).toBe(blockCursor({ offset: 4 })) + }) + + it('rejects a cursor replayed after a filter change', async () => { + const cursor = blockCursor({ offset: 2 }) + + const response = await GET( + request( + `/api/v2/blocks?workspaceId=${WORKSPACE_ID}&capability=trigger&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it.each([ + ['an unknown param', 'bogus=1'], + ['a fractional limit', 'limit=1.5'], + ['a zero limit', 'limit=0'], + ['an over-cap limit', 'limit=101'], + ['an empty search', 'search='], + ['an unknown sort field', 'sortBy=popularity'], + ['an unknown capability', 'capability=response'], + ])('rejects %s instead of ignoring it', async (_label, query) => { + const response = await GET(request(`/api/v2/blocks?workspaceId=${WORKSPACE_ID}&${query}`)) + + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('names the bound in a limit rejection', async () => { + const response = await GET(request(`/api/v2/blocks?workspaceId=${WORKSPACE_ID}&limit=101`)) + + expect((await response.json()).error.message).toContain('limit cannot exceed 100') + }) + + it('conceals a workspace the caller cannot reach as absent', async () => { + mocks.list.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found')) + + const response = await GET(request(`/api/v2/blocks?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Workspace not found', + }) + }) +}) + +describe('/api/v2/blocks/[blockId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.read.mockResolvedValue({ block: detail }) + }) + + it('returns one block in the single-resource envelope', async () => { + const response = await GET_BLOCK(request(`/api/v2/blocks/slack?workspaceId=${WORKSPACE_ID}`), { + params: Promise.resolve({ blockId: 'slack' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: detail }) + expect(mocks.read).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, blockId: 'slack' }, + request: expect.anything(), + }) + }) + + it('requires the workspace whose availability rules decide the answer', async () => { + const response = await GET_BLOCK(request('/api/v2/blocks/slack'), { + params: Promise.resolve({ blockId: 'slack' }), + }) + + expect(response.status).toBe(400) + expect(mocks.read).not.toHaveBeenCalled() + }) + + it('answers not found for a block this caller cannot see', async () => { + mocks.read.mockRejectedValue(new OrchestrationError('not_found', 'Block not found')) + + const response = await GET_BLOCK( + request(`/api/v2/blocks/preview_thing?workspaceId=${WORKSPACE_ID}`), + { params: Promise.resolve({ blockId: 'preview_thing' }) } + ) + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Block not found') + }) +}) diff --git a/apps/sim/app/api/v2/blocks/route.ts b/apps/sim/app/api/v2/blocks/route.ts new file mode 100644 index 00000000000..3c07c77cfd3 --- /dev/null +++ b/apps/sim/app/api/v2/blocks/route.ts @@ -0,0 +1,56 @@ +import { type V2ListBlocksQuery, v2ListBlocksContract } from '@/lib/api/contracts/v2/catalog' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { listCatalogBlocks } from '@/lib/catalog/application/list-blocks' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Every param that changes which blocks, in which order, this list returns. */ +function blockCursorFilters(query: V2ListBlocksQuery) { + return cursorScopeKey(cursorRoute(v2ListBlocksContract), { + workspaceId: query.workspaceId, + search: query.search, + category: query.category, + capability: query.capability, + source: query.source, + }) +} + +/** GET /api/v2/blocks — List the blocks available in a workspace. */ +export const GET = defineV2JsonRoute({ + contract: v2ListBlocksContract, + operation: catalogOperations.listBlocks, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + /** + * An offset cursor, matching `GET /api/v2/skills`: the sequence merges a + * static code registry with per-workspace DB rows and re-sorts in JS, so no + * ordered SQL read exists for a keyset predicate to act on. Every param that + * decides which sequence that is gets stamped into the token; `limit` does + * not, because it selects how much of the sequence to return. + */ + mapInput: ({ query }) => ({ + ...query, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + blockCursorFilters(query) + ), + }), + useCase: listCatalogBlocks, + present: ({ entries, hasMore, offset, limit }, { query }) => ({ + data: entries, + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + blockCursorFilters(query), + offset + limit + ) + : null, + }), +}) diff --git a/apps/sim/app/api/v2/connector-types/route.test.ts b/apps/sim/app/api/v2/connector-types/route.test.ts new file mode 100644 index 00000000000..2d4578661b8 --- /dev/null +++ b/apps/sim/app/api/v2/connector-types/route.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ connectorTypes: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/catalog/application/list-connector-types', () => ({ + listCatalogConnectorTypes: { + operation: { id: 'catalog.connector_types.list' }, + execute: mocks.connectorTypes, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/v2/connector-types/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const connectorType = { + connectorType: 'google_drive', + name: 'Google Drive', + description: 'Sync Drive documents.', + version: '1.0.0', + auth: { mode: 'oauth' as const, provider: 'google-drive' }, + configFields: [ + { + id: 'folderSelector', + title: 'Folder', + type: 'selector' as const, + canonicalParamId: 'folderId', + mode: 'basic' as const, + multi: true, + }, + ], + supportsIncrementalSync: true, + tagDefinitions: [], +} + +function request(url: string) { + return new NextRequest(`http://localhost:3000${url}`, { headers: { 'x-api-key': 'key' } }) +} + +describe('/api/v2/connector-types', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.connectorTypes.mockResolvedValue({ connectorTypes: [connectorType] }) + }) + + it('returns the whole catalog in one page and keeps it out of shared caches', async () => { + const response = await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(await response.json()).toEqual({ data: [connectorType], nextCursor: null }) + }) + + it('publishes the multi and canonical-pair properties a caller configures against', async () => { + const response = await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}`)) + + const [field] = (await response.json()).data[0].configFields + expect(field.multi).toBe(true) + expect(field.canonicalParamId).toBe('folderId') + }) + + it('rejects pagination params a full-set list does not implement', async () => { + const response = await GET( + request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&limit=1`) + ) + + expect(response.status).toBe(400) + expect(mocks.connectorTypes).not.toHaveBeenCalled() + }) + + it('requires the workspace whose availability rules decide the answer', async () => { + expect((await GET(request('/api/v2/connector-types'))).status).toBe(400) + expect(mocks.connectorTypes).not.toHaveBeenCalled() + }) + + it('conceals a workspace the caller cannot reach as absent', async () => { + mocks.connectorTypes.mockRejectedValue( + new OrchestrationError('not_found', 'Workspace not found') + ) + + const response = await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Workspace not found', + }) + }) +}) diff --git a/apps/sim/app/api/v2/connector-types/route.ts b/apps/sim/app/api/v2/connector-types/route.ts new file mode 100644 index 00000000000..176f5c0de6f --- /dev/null +++ b/apps/sim/app/api/v2/connector-types/route.ts @@ -0,0 +1,20 @@ +import { v2ListConnectorTypesContract } from '@/lib/api/contracts/v2/catalog' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { listCatalogConnectorTypes } from '@/lib/catalog/application/list-connector-types' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/connector-types — List every knowledge-base connector type. */ +export const GET = defineV2JsonRoute({ + contract: v2ListConnectorTypesContract, + operation: catalogOperations.listConnectorTypes, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + mapInput: ({ query }) => query, + useCase: listCatalogConnectorTypes, + present: ({ connectorTypes }) => ({ data: connectorTypes, nextCursor: null }), +}) diff --git a/apps/sim/app/api/v2/enrichments/route.test.ts b/apps/sim/app/api/v2/enrichments/route.test.ts new file mode 100644 index 00000000000..4ac3d569466 --- /dev/null +++ b/apps/sim/app/api/v2/enrichments/route.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ enrichments: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/catalog/application/list-connector-types', () => ({ + listCatalogConnectorTypes: { + operation: { id: 'catalog.connector_types.list' }, + execute: mocks.connectorTypes, + }, +})) +vi.mock('@/lib/catalog/application/list-enrichments', () => ({ + listCatalogEnrichments: { + operation: { id: 'catalog.enrichments.list' }, + execute: mocks.enrichments, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/v2/enrichments/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} + +const enrichment = { + id: 'work-email', + name: 'Work email', + description: 'Find a work email.', + inputs: [{ id: 'fullName', name: 'Full name', type: 'string' as const, required: true }], + outputs: [{ id: 'email', name: 'Work email', type: 'string' as const }], + providers: [{ id: 'hunter', label: 'Hunter', toolId: 'hunter_email_finder' }], +} + +function request(url: string) { + return new NextRequest(`http://localhost:3000${url}`, { headers: { 'x-api-key': 'key' } }) +} + +describe('/api/v2/enrichments', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.enrichments.mockResolvedValue({ enrichments: [enrichment] }) + }) + + it('returns the whole catalog in one page and keeps it out of shared caches', async () => { + const response = await GET(request(`/api/v2/enrichments?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(await response.json()).toEqual({ data: [enrichment], nextCursor: null }) + }) + + it('publishes the provider cascade in the order it is attempted', async () => { + const response = await GET(request(`/api/v2/enrichments?workspaceId=${WORKSPACE_ID}`)) + + expect((await response.json()).data[0].providers).toEqual(enrichment.providers) + }) + + it('rejects pagination params a full-set list does not implement', async () => { + const response = await GET(request(`/api/v2/enrichments?workspaceId=${WORKSPACE_ID}&cursor=x`)) + + expect(response.status).toBe(400) + expect(mocks.enrichments).not.toHaveBeenCalled() + }) + + it('requires the workspace whose availability rules decide the answer', async () => { + expect((await GET(request('/api/v2/enrichments'))).status).toBe(400) + expect(mocks.enrichments).not.toHaveBeenCalled() + }) + + it('conceals a workspace the caller cannot reach as absent', async () => { + mocks.enrichments.mockRejectedValue(new OrchestrationError('not_found', 'Workspace not found')) + + const response = await GET(request(`/api/v2/enrichments?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(404) + expect((await response.json()).error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Workspace not found', + }) + }) +}) diff --git a/apps/sim/app/api/v2/enrichments/route.ts b/apps/sim/app/api/v2/enrichments/route.ts new file mode 100644 index 00000000000..c2a5eb56cbf --- /dev/null +++ b/apps/sim/app/api/v2/enrichments/route.ts @@ -0,0 +1,20 @@ +import { v2ListEnrichmentsContract } from '@/lib/api/contracts/v2/catalog' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { listCatalogEnrichments } from '@/lib/catalog/application/list-enrichments' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/enrichments — List every code-defined table enrichment. */ +export const GET = defineV2JsonRoute({ + contract: v2ListEnrichmentsContract, + operation: catalogOperations.listEnrichments, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + mapInput: ({ query }) => query, + useCase: listCatalogEnrichments, + present: ({ enrichments }) => ({ data: enrichments, nextCursor: null }), +}) diff --git a/apps/sim/app/api/v2/lib/catalog.ts b/apps/sim/app/api/v2/lib/catalog.ts new file mode 100644 index 00000000000..9ff186b130d --- /dev/null +++ b/apps/sim/app/api/v2/lib/catalog.ts @@ -0,0 +1,12 @@ +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes' + +/** + * The error policy every catalog route shares. + * + * A workspace the caller cannot reach is concealed as absent, so the catalog + * routes cannot be used to probe which workspace ids exist. Each detail route + * additionally raises its own not-found for an unknown or gated resource. + */ +export const catalogErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) diff --git a/apps/sim/app/api/v2/tools/[toolId]/route.ts b/apps/sim/app/api/v2/tools/[toolId]/route.ts new file mode 100644 index 00000000000..450a624c38f --- /dev/null +++ b/apps/sim/app/api/v2/tools/[toolId]/route.ts @@ -0,0 +1,23 @@ +import { v2GetToolContract } from '@/lib/api/contracts/v2/catalog' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { getCatalogTool } from '@/lib/catalog/application/get-tool' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** GET /api/v2/tools/{toolId} — Read one built-in tool's parameters and outputs. */ +export const GET = defineV2JsonRoute({ + contract: v2GetToolContract, + operation: catalogOperations.readTool, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + toolId: params.toolId, + }), + useCase: getCatalogTool, + present: ({ tool }) => ({ data: tool }), +}) diff --git a/apps/sim/app/api/v2/tools/route.test.ts b/apps/sim/app/api/v2/tools/route.test.ts new file mode 100644 index 00000000000..651100ef7c6 --- /dev/null +++ b/apps/sim/app/api/v2/tools/route.test.ts @@ -0,0 +1,181 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ list: vi.fn(), read: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/catalog/application/list-tools', () => ({ + listCatalogTools: { operation: { id: 'catalog.tools.list' }, execute: mocks.list }, +})) +vi.mock('@/lib/catalog/application/get-tool', () => ({ + getCatalogTool: { operation: { id: 'catalog.tools.read' }, execute: mocks.read }, +})) + +import { v2ListToolsContract } from '@/lib/api/contracts/v2/catalog' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { cursorSortKey, encodeOffsetCursor } from '@/app/api/v2/lib/response' +import { GET as GET_TOOL } from '@/app/api/v2/tools/[toolId]/route' +import { GET } from '@/app/api/v2/tools/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' + +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +const summary = { + id: 'slack_message', + name: 'Slack Send Message', + description: 'Send a message.', + version: '1.0.0', + hostedApiKey: 'none' as const, +} + +const detail = { ...summary, params: {}, outputs: {} } + +function toolCursor({ + offset, + search, + hostedApiKey, + oauthProvider, + sortBy = 'id', + sortOrder = 'asc', +}: { + offset: number + search?: string + hostedApiKey?: string + oauthProvider?: string + sortBy?: string + sortOrder?: string +}): string { + return encodeOffsetCursor( + cursorSortKey(sortBy, sortOrder), + cursorScopeKey(cursorRoute(v2ListToolsContract), { + workspaceId: WORKSPACE_ID, + search, + hostedApiKey, + oauthProvider, + }), + offset + ) +} + +function request(url: string) { + return new NextRequest(`http://localhost:3000${url}`, { headers: { 'x-api-key': 'key' } }) +} + +describe('/api/v2/tools', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.list.mockResolvedValue({ entries: [summary], hasMore: false, offset: 0, limit: 50 }) + mocks.read.mockResolvedValue({ tool: detail }) + }) + + it('returns tool summaries without params or outputs', async () => { + const response = await GET(request(`/api/v2/tools?workspaceId=${WORKSPACE_ID}`)) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + const body = await response.json() + expect(body.data[0]).not.toHaveProperty('params') + expect(body.nextCursor).toBeNull() + }) + + it('resumes from the offset cursor and mints the next one while pages remain', async () => { + mocks.list.mockResolvedValue({ entries: [summary], hasMore: true, offset: 100, limit: 100 }) + const cursor = toolCursor({ offset: 100 }) + + const response = await GET( + request( + `/api/v2/tools?workspaceId=${WORKSPACE_ID}&limit=100&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect((await response.json()).nextCursor).toBe(toolCursor({ offset: 200 })) + }) + + it('rejects a cursor replayed after the hosted-key filter changes', async () => { + const cursor = toolCursor({ offset: 100 }) + + const response = await GET( + request( + `/api/v2/tools?workspaceId=${WORKSPACE_ID}&hostedApiKey=always&cursor=${encodeURIComponent(cursor)}` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it.each([ + ['an unknown param', 'bogus=1'], + ['a fractional limit', 'limit=2.5'], + ['an unknown hosted-key value', 'hostedApiKey=maybe'], + ['an empty oauth provider', 'oauthProvider='], + ])('rejects %s instead of ignoring it', async (_label, query) => { + const response = await GET(request(`/api/v2/tools?workspaceId=${WORKSPACE_ID}&${query}`)) + + expect(response.status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) +}) + +describe('/api/v2/tools/[toolId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.read.mockResolvedValue({ tool: detail }) + }) + + it('returns one tool with its params and outputs', async () => { + const response = await GET_TOOL( + request(`/api/v2/tools/slack_message?workspaceId=${WORKSPACE_ID}`), + { params: Promise.resolve({ toolId: 'slack_message' }) } + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: detail }) + expect(mocks.read).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, toolId: 'slack_message' }, + request: expect.anything(), + }) + }) + + it('answers not found for a tool this caller cannot run', async () => { + mocks.read.mockRejectedValue(new OrchestrationError('not_found', 'Tool not found')) + + const response = await GET_TOOL( + request(`/api/v2/tools/secret_tool?workspaceId=${WORKSPACE_ID}`), + { params: Promise.resolve({ toolId: 'secret_tool' }) } + ) + + expect(response.status).toBe(404) + expect((await response.json()).error.message).toBe('Tool not found') + }) +}) diff --git a/apps/sim/app/api/v2/tools/route.ts b/apps/sim/app/api/v2/tools/route.ts new file mode 100644 index 00000000000..fdf4b1f4757 --- /dev/null +++ b/apps/sim/app/api/v2/tools/route.ts @@ -0,0 +1,49 @@ +import { type V2ListToolsQuery, v2ListToolsContract } from '@/lib/api/contracts/v2/catalog' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { listCatalogTools } from '@/lib/catalog/application/list-tools' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** Every param that changes which tools, in which order, this list returns. */ +function toolCursorFilters(query: V2ListToolsQuery) { + return cursorScopeKey(cursorRoute(v2ListToolsContract), { + workspaceId: query.workspaceId, + search: query.search, + hostedApiKey: query.hostedApiKey, + oauthProvider: query.oauthProvider, + }) +} + +/** GET /api/v2/tools — List the built-in tools available in a workspace. */ +export const GET = defineV2JsonRoute({ + contract: v2ListToolsContract, + operation: catalogOperations.listTools, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: catalogErrorPolicy, + /** An offset cursor for the same reason as `GET /api/v2/blocks`. */ + mapInput: ({ query }) => ({ + ...query, + offset: decodeOffsetCursor( + query.cursor, + cursorSortKey(query.sortBy, query.sortOrder), + toolCursorFilters(query) + ), + }), + useCase: listCatalogTools, + present: ({ entries, hasMore, offset, limit }, { query }) => ({ + data: entries, + nextCursor: hasMore + ? encodeOffsetCursor( + cursorSortKey(query.sortBy, query.sortOrder), + toolCursorFilters(query), + offset + limit + ) + : null, + }), +}) diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index d52ec521993..128a145c39e 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -47,6 +47,7 @@ import { const PAGED_LISTS = [ 'GET /api/v2/audit-logs', 'GET /api/v2/billing/logs', + 'GET /api/v2/blocks', 'GET /api/v2/credentials', 'GET /api/v2/custom-tools', 'GET /api/v2/files', @@ -59,6 +60,7 @@ const PAGED_LISTS = [ 'GET /api/v2/tables', 'GET /api/v2/tables/[tableId]/rows', 'POST /api/v2/tables/[tableId]/query', + 'GET /api/v2/tools', 'GET /api/v2/workflows', 'GET /api/v2/workflows/[id]/runs', 'GET /api/v2/workflows/[id]/versions', @@ -80,12 +82,19 @@ const PAGED_LISTS = [ * not appear here. * - The credential-provider catalog is bounded by the code-defined OAuth and * service-account registries. + * - The connector-type and enrichment catalogs are bounded the same way, by the + * code-defined connector-meta and enrichment registries. The block and tool + * catalogs are NOT — a workspace adds blocks by deploying workflows as blocks, + * and there are ~5,000 tool ids — which is why those two are paged and do not + * appear here. * - A knowledge base has a fixed number of tag slots, so its tag vocabulary * cannot grow past them. * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ + 'GET /api/v2/connector-types', 'GET /api/v2/credentials/providers', + 'GET /api/v2/enrichments', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', @@ -130,6 +139,15 @@ const CURSOR_BINDINGS: Record = { 'endDate', ], 'GET /api/v2/billing/logs': ['source', 'workspaceId', 'period', 'startDate', 'endDate'], + 'GET /api/v2/blocks': [ + 'workspaceId', + 'search', + 'category', + 'capability', + 'source', + 'sortBy', + 'sortOrder', + ], 'GET /api/v2/credentials': ['workspaceId', 'type', 'providerId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/custom-tools': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/files': [ @@ -173,6 +191,14 @@ const CURSOR_BINDINGS: Record = { 'GET /api/v2/tables': ['workspaceId', 'folderPath', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/tables/[tableId]/rows': [], 'POST /api/v2/tables/[tableId]/query': ['predicate', 'sort'], + 'GET /api/v2/tools': [ + 'workspaceId', + 'search', + 'hostedApiKey', + 'oauthProvider', + 'sortBy', + 'sortOrder', + ], 'GET /api/v2/workflows': [ 'workspaceId', 'folderPath', diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts new file mode 100644 index 00000000000..b244b395b21 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/catalog.ts @@ -0,0 +1,816 @@ +import { z } from 'zod' +import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { columnTypeSchema } from '@/lib/api/contracts/tables' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, + v2SearchSchema, + v2SortFields, +} from '@/lib/api/contracts/v2/shared' + +/** + * v2 catalog contracts: the code-defined blocks, tools, connector types, and + * enrichments a caller can build with. + * + * These read like static reference data and are not: what a caller may place is + * decided per workspace by its permission-group integration allowlist, per + * organization by which unreleased blocks have been revealed, per deployment by + * `ALLOWED_INTEGRATIONS`, and per workspace again by the workflows it has + * deployed as blocks. So every operation takes a `workspaceId` and every + * response is `Cache-Control: private, no-store` like the rest of v2 — an + * unrevealed preview block's existence must not leak across organizations + * through a shared cache. + * + * The list/detail split is what keeps the lists bounded: a block summary names + * its tools and operations by id, and resolving one is a second call. Projecting + * all 300-odd blocks with every field, operation, and tool schema would be + * several megabytes. + */ + +const catalogIdSchema = z + .string() + .trim() + .min(1, 'id cannot be empty') + .max(255, 'id must be at most 255 characters') + +/** Workspace whose availability rules are applied to every catalog read. */ +const catalogWorkspaceQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe( + 'Workspace whose integration allowlist, revealed preview blocks, and deployed custom blocks decide what this catalog contains.' + ), + }) + .strict() + +const catalogConditionValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.union([z.string(), z.number(), z.boolean()])), +]) + +/** + * When a configuration field applies: "the field named by `field` holds + * `value`". `not` inverts the match, and `and` adds a second clause that must + * hold as well. + */ +export const v2CatalogConditionSchema = z + .object({ + field: z.string().describe('Sibling field id whose value decides this condition.'), + value: catalogConditionValueSchema.describe( + 'Value, or set of accepted values, the named field must hold.' + ), + not: z.boolean().optional().describe('Invert the match: every value EXCEPT `value`.'), + and: z + .object({ + field: z.string().describe('Sibling field id for the second clause.'), + value: catalogConditionValueSchema + .optional() + .describe('Value the second clause matches. Absent means "holds any value".'), + not: z.boolean().optional().describe('Invert the second clause.'), + }) + .optional() + .describe('A second clause that must hold as well.'), + }) + .meta({ + id: 'V2CatalogCondition', + title: 'Catalog condition', + description: 'When a configuration field applies, expressed against a sibling field.', + }) +export type V2CatalogCondition = z.output + +const catalogDependsOnSchema = z.union([ + z.array(z.string()), + z.object({ + all: z.array(z.string()).optional().describe('Every listed field must hold a value.'), + any: z.array(z.string()).optional().describe('At least one listed field must hold a value.'), + }), +]) + +/** One configuration field on a block. */ +export const v2BlockFieldSchema = z + .object({ + id: z.string().describe('Field identifier, and the key its value is stored under.'), + type: z.string().describe('Editor control the field renders as, e.g. `short-input`.'), + title: z.string().optional().describe('Human-readable label.'), + required: z + .boolean() + .optional() + .describe( + 'Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.' + ), + requiredWhen: v2CatalogConditionSchema + .optional() + .describe('Condition under which the field is required.'), + description: z.string().optional().describe('Authored explanation of the field.'), + placeholder: z.string().optional().describe('Placeholder shown in the editor.'), + mode: z + .string() + .optional() + .describe( + 'Where the field renders: `basic`, `advanced`, `both`, `trigger`, or `trigger-advanced`.' + ), + hidden: z.boolean().optional().describe('Whether the field is hidden in the editor.'), + condition: v2CatalogConditionSchema + .optional() + .describe('Condition under which the field applies at all.'), + options: z + .array( + z.object({ + id: z.string().describe('Value stored when this option is selected.'), + label: z.string().optional().describe('Human-readable option label.'), + hasIcon: z + .boolean() + .optional() + .describe('Whether the option renders with an icon. The icon itself is not published.'), + }) + ) + .optional() + .describe( + 'Selectable options. Absent on fields whose options are fetched per workspace at edit time.' + ), + min: z.number().optional().describe('Minimum accepted numeric value.'), + max: z.number().optional().describe('Maximum accepted numeric value.'), + step: z.number().optional().describe('Increment for numeric controls.'), + integer: z.boolean().optional().describe('Whether the numeric value must be a whole number.'), + rows: z.number().optional().describe('Visible row count for multi-line text.'), + password: z.boolean().optional().describe('Whether the stored value is masked in the editor.'), + multiSelect: z.boolean().optional().describe('Whether more than one option may be selected.'), + language: z.string().optional().describe('Language of a code field.'), + generationType: z.string().optional().describe('Kind of content AI assistance generates here.'), + serviceId: z.string().optional().describe('OAuth service this credential field authenticates.'), + requiredScopes: z + .array(z.string()) + .optional() + .describe('OAuth scopes the credential selected here must carry.'), + mimeType: z.string().optional().describe('MIME type filter applied to a file picker.'), + acceptedTypes: z.string().optional().describe('Accepted file extensions for an upload field.'), + multiple: z.boolean().optional().describe('Whether more than one file may be supplied.'), + maxSize: z.number().optional().describe('Maximum upload size in megabytes.'), + connectionDroppable: z + .boolean() + .optional() + .describe('Whether another block’s output can be dropped onto this field.'), + columns: z.array(z.string()).optional().describe('Column headings for a table field.'), + dependsOn: catalogDependsOnSchema + .optional() + .describe('Sibling fields this field is cleared by when they change.'), + canonicalParamId: z + .string() + .optional() + .describe( + 'Shared key for a picker/manual-entry pair. Both fields write the same value, so supply exactly one of the pair.' + ), + defaultValue: z + .union([ + z.string(), + z.number(), + z.boolean(), + z.record( + z.string(), + z.unknown().describe('Member of an object-valued default. Shape varies by field type.') + ), + z.array( + z.unknown().describe('Element of an array-valued default. Shape varies by field type.') + ), + ]) + .optional() + .describe('Value used when the field is left unset.'), + hasComputedDefault: z + .boolean() + .optional() + .describe( + 'Whether the field derives its value from the block’s other values. The deriving function is not published.' + ), + }) + .meta({ + id: 'V2BlockField', + title: 'Block field', + description: 'One configuration field on a block.', + }) +export type V2BlockField = z.output + +const catalogBlockSourceSchema = z + .enum(['builtin', 'custom']) + .describe( + 'Where the block comes from: `builtin` is the shipped registry, `custom` is a workflow this workspace deployed as a block.' + ) + +/** Summary view of a block. */ +export const v2BlockSummarySchema = z + .object({ + id: z.string().describe('Block type identifier, used as a workflow block’s `type`.'), + name: z.string().describe('Display name.'), + description: z.string().describe('One-line summary of what the block does.'), + longDescription: z + .string() + .optional() + .describe('Extended explanation, when the block has one.'), + category: z.string().describe('Toolbar category: `blocks`, `tools`, or `triggers`.'), + integrationType: z + .string() + .optional() + .describe('Integration category, e.g. `communication`, `databases`.'), + source: catalogBlockSourceSchema, + authMode: z + .string() + .optional() + .describe('How the block authenticates: `oauth`, `api_key`, or `bot_token`.'), + triggerAllowed: z.boolean().describe('Whether the block declares itself usable as a trigger.'), + triggerCapable: z + .boolean() + .describe( + 'Whether the block can start a workflow — a trigger-category block, one declaring `triggerAllowed`, or one with trigger-mode fields.' + ), + triggerIds: z.array(z.string()).describe('Identifiers of the triggers this block supports.'), + toolIds: z + .array(z.string()) + .describe( + 'Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`.' + ), + operationIds: z + .array(z.string()) + .describe( + 'Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`.' + ), + preview: z + .boolean() + .describe('Whether the block is unreleased and revealed only to this caller.'), + sunset: z + .object({ + status: z + .enum(['legacy', 'deprecated']) + .describe('`legacy` is superseded but supported; `deprecated` is slated for removal.'), + replacedBy: z.string().optional().describe('Block type to migrate to, when one exists.'), + }) + .optional() + .describe('Post-release lifecycle state. Absent for a block in normal support.'), + docsLink: z.string().optional().describe('Sim documentation page for the integration.'), + tags: z.array(z.string()).describe('Catalog tags, e.g. `messaging`, `version-control`.'), + }) + .meta({ + id: 'V2BlockSummary', + title: 'Block summary', + description: 'List view of a block: what it is and what it references, by id.', + }) +export type V2BlockSummary = z.output + +const v2BlockOutputSchema = z.object({ + type: z.string().describe('Value type of the output.'), + description: z.string().optional().describe('What the output holds.'), +}) + +/** Block-level input definition. */ +const v2BlockInputDefinitionSchema = z.object({ + type: z + .string() + .describe('Value type: `string`, `number`, `boolean`, `json`, `array`, or `file`.'), + description: z.string().optional().describe('What the input means.'), + // untyped-response: block input JSON Schema is authored per block and arbitrarily nested + schema: z + .unknown() + .optional() + .describe('JSON-Schema-shaped structure for object and array inputs.'), +}) + +const v2ToolParamSchema = z + .object({ + type: z.string().describe('Parameter value type.'), + required: z.boolean().optional().describe('Whether the parameter must be supplied.'), + visibility: z + .string() + .optional() + .describe('Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.'), + description: z.string().optional().describe('What the parameter means.'), + default: z.unknown().optional().describe('Value used when the parameter is omitted.'), + // untyped-response: tool param JSON Schema is provider-defined and arbitrarily nested + items: z.unknown().optional().describe('JSON-Schema-shaped constraints for structured params.'), + }) + .meta({ + id: 'V2ToolParam', + title: 'Tool parameter', + description: 'One declared parameter of a built-in tool.', + }) +export type V2ToolParam = z.output + +/** + * One declared output field of a tool. + * + * An object output's members and an array output's element shape are published + * open rather than as a self-referential schema. That is the same treatment + * `V2McpTool.inputSchema` gives a server-authored argument schema, and it is + * what keeps the generated document free of anonymous recursive components: a + * `z.lazy` cycle publishes as an unnamed `$ref` that no generated client can + * name. The nesting is still returned in full — only its schema is open. + */ +export const v2ToolOutputSchema = z + .object({ + type: z.string().describe('Value type of the output field.'), + description: z.string().optional().describe('What the field holds.'), + optional: z.boolean().optional().describe('Whether the field may be absent.'), + nullable: z.boolean().optional().describe('Whether the field may be null.'), + properties: z + .record( + z.string(), + // untyped-response: a nested output field has the same open shape as this one + z + .unknown() + .describe('Nested output field, in this same shape.') + ) + .optional() + .describe('Members of an object-typed output, keyed by field name.'), + items: z + .object({ + type: z.string().describe('Element value type.'), + description: z.string().optional().describe('What an element holds.'), + properties: z + .record( + z.string(), + // untyped-response: a nested output field has the same open shape as this one + z + .unknown() + .describe('Nested output field, in this same shape.') + ) + .optional() + .describe('Members of an object-typed element, keyed by field name.'), + }) + .optional() + .describe('Element shape of an array-typed output.'), + fileConfig: z + .object({ + mimeType: z.string().optional().describe('MIME type of the produced file.'), + extension: z.string().optional().describe('File extension of the produced file.'), + }) + .optional() + .describe('File metadata for a file-typed output.'), + }) + .meta({ + id: 'V2ToolOutput', + title: 'Tool output', + description: 'One declared output field of a built-in tool.', + }) +export type V2ToolOutput = z.output + +const v2HostedApiKeySchema = z + .enum(['always', 'conditional', 'none']) + .describe( + 'Whether Sim supplies the API key: `always`, `conditional` (only for some parameter combinations), or `none` (bring your own).' + ) + +const v2ToolOAuthSchema = z.object({ + required: z.boolean().describe('Whether the tool cannot run without an OAuth credential.'), + provider: z.string().describe('OAuth service the credential must authenticate.'), + requiredScopes: z.array(z.string()).optional().describe('Scopes the credential must carry.'), +}) + +/** Summary view of a built-in tool. */ +export const v2ToolSummarySchema = z + .object({ + id: z.string().describe('Registered tool identifier, including its version suffix.'), + name: z.string().describe('Display name.'), + description: z.string().describe('What the tool does.'), + version: z.string().optional().describe('Tool version.'), + hostedApiKey: v2HostedApiKeySchema, + oauth: v2ToolOAuthSchema.optional().describe('OAuth requirement, when the tool has one.'), + }) + .meta({ + id: 'V2ToolSummary', + title: 'Tool summary', + description: 'List view of a built-in tool: identity, auth, and key hosting.', + }) +export type V2ToolSummary = z.output + +/** Detail view of a built-in tool. */ +export const v2ToolDetailSchema = v2ToolSummarySchema + .extend({ + params: z.record(z.string(), v2ToolParamSchema).describe('Parameters the tool accepts.'), + outputs: z.record(z.string(), v2ToolOutputSchema).describe('Fields the tool produces.'), + }) + .meta({ + id: 'V2ToolDetail', + title: 'Tool', + description: 'A built-in tool with its declared parameters and outputs.', + }) +export type V2ToolDetail = z.output + +/** + * One value an operation needs. + * + * An operation's inputs come from two places — the tool's own declared params + * and the block's operation-scoped input definitions — and the two carry + * different structure keys (`items` versus `schema`). This is one schema with + * both rather than a union of the two, because a union of two open object + * shapes resolves to whichever member matches first and silently strips the key + * that distinguished them: a block input's `schema` disappeared into the tool + * param member, which validated fine and published an incomplete field. + */ +const v2OperationInputSchema = z + .object({ + type: z.string().describe('Value type.'), + required: z.boolean().optional().describe('Whether the value must be supplied.'), + visibility: z + .string() + .optional() + .describe('Who may supply the value: `user-or-llm`, `user-only`, `llm-only`, or `hidden`.'), + description: z.string().optional().describe('What the value means.'), + default: z.unknown().optional().describe('Value used when this input is omitted.'), + // untyped-response: tool param JSON Schema is provider-defined and arbitrarily nested + items: z + .unknown() + .optional() + .describe('JSON-Schema-shaped constraints declared by the tool parameter.'), + // untyped-response: block input JSON Schema is authored per block and arbitrarily nested + schema: z + .unknown() + .optional() + .describe('JSON-Schema-shaped structure declared by the block input.'), + }) + .meta({ + id: 'V2OperationInput', + title: 'Operation input', + description: 'One value a block operation needs, from its tool or its block-level inputs.', + }) + +const v2BlockOperationSchema = z.object({ + toolId: z.string().optional().describe('Built-in tool that performs this operation.'), + toolName: z.string().optional().describe('Display name of that tool.'), + description: z.string().optional().describe('What the operation does.'), + inputs: z + .record(z.string(), v2OperationInputSchema) + .describe( + 'Values this operation needs, excluding the ones the block supplies from its own block-level inputs.' + ), + outputs: z.record(z.string(), v2ToolOutputSchema).describe('Fields the operation produces.'), + inputSchema: z + .array(v2BlockFieldSchema) + .describe('Configuration fields that appear when this operation is selected.'), +}) + +const v2BlockTriggerSchema = z.object({ + id: z.string().describe('Trigger identifier.'), + outputs: z + .record(z.string(), v2BlockOutputSchema) + .describe('Top-level fields the trigger event delivers.'), + configFields: z + .record( + z.string(), + z.object({ + type: z.string().describe('Editor control the field renders as.'), + required: z.boolean().describe('Whether a value must be supplied.'), + title: z.string().optional().describe('Human-readable label.'), + description: z.string().optional().describe('Authored explanation of the field.'), + placeholder: z.string().optional().describe('Placeholder shown in the editor.'), + default: z.unknown().optional().describe('Value used when the field is left unset.'), + options: z + .array( + z.object({ + id: z.string().describe('Value stored when this option is selected.'), + label: z.string().describe('Human-readable option label.'), + }) + ) + .optional() + .describe('Selectable options.'), + condition: v2CatalogConditionSchema + .optional() + .describe('Condition under which the field applies.'), + }) + ) + .describe('Fields that configure the trigger, keyed by field id.'), +}) + +/** Detail view of a block: the summary plus everything needed to configure one. */ +export const v2BlockDetailSchema = v2BlockSummarySchema + .extend({ + bestPractices: z + .string() + .optional() + .describe('Authored guidance on using the block correctly.'), + inputSchema: z + .array(v2BlockFieldSchema) + .describe('Configuration fields that apply regardless of the selected operation.'), + operationInputSchema: z + .record(z.string(), z.array(v2BlockFieldSchema)) + .describe('Configuration fields keyed by the operation that reveals them.'), + inputDefinitions: z + .record(z.string(), v2BlockInputDefinitionSchema) + .describe('Block-level input definitions, keyed by parameter name.'), + operations: z + .record(z.string(), v2BlockOperationSchema) + .describe('Operations the block exposes, keyed by operation id.'), + tools: z + .array(v2ToolDetailSchema) + .describe('Every built-in tool the block can run, with parameters and outputs.'), + triggers: z.array(v2BlockTriggerSchema).describe('Triggers the block can run on.'), + outputs: z.record(z.string(), v2BlockOutputSchema).describe('Fields the block produces.'), + }) + .meta({ + id: 'V2BlockDetail', + title: 'Block', + description: 'A block with its configuration fields, operations, tools, and triggers.', + }) +export type V2BlockDetail = z.output + +/** One field of a connector's `sourceConfig`. */ +export const v2ConnectorConfigFieldSchema = z + .object({ + id: z.string().describe('Field identifier.'), + title: z.string().describe('Human-readable label.'), + type: z + .enum(['short-input', 'dropdown', 'selector']) + .describe( + 'Control the field renders as. A `selector` fetches its options from the connected account.' + ), + placeholder: z.string().optional().describe('Placeholder shown in the editor.'), + required: z.boolean().optional().describe('Whether a value must be supplied.'), + description: z.string().optional().describe('Authored explanation of the field.'), + options: z + .array( + z.object({ + id: z.string().describe('Value stored when this option is selected.'), + label: z.string().describe('Human-readable option label.'), + }) + ) + .optional() + .describe('Static options, for a `dropdown` field.'), + selectorKey: z + .string() + .optional() + .describe( + 'Names the picker a `selector` field renders. Its options are fetched per workspace.' + ), + mimeType: z.string().optional().describe('MIME type filter applied to the picker.'), + dependsOn: catalogDependsOnSchema + .optional() + .describe('Sibling fields this field is cleared by when they change.'), + mode: z + .enum(['basic', 'advanced']) + .optional() + .describe( + 'Which half of a canonical pair this field is: `basic` is the picker, `advanced` the manual entry.' + ), + canonicalParamId: z + .string() + .optional() + .describe( + 'Shared `sourceConfig` key for a picker/manual-entry pair. Send exactly one of the pair, keyed by this value rather than by the field’s own `id`.' + ), + multi: z + .boolean() + .optional() + .describe( + 'When true the stored `sourceConfig` value is a `string[]`, not a `string`: a `selector` renders a multi-select picker and a `short-input` accepts a comma-separated list.' + ), + }) + .meta({ + id: 'V2ConnectorConfigField', + title: 'Connector config field', + description: 'One field of a knowledge-base connector’s source configuration.', + }) +export type V2ConnectorConfigField = z.output + +/** A knowledge-base connector type. */ +export const v2ConnectorTypeSchema = z + .object({ + connectorType: z + .string() + .describe('Exact identifier to send when creating a connector of this type.'), + name: z.string().describe('Display name.'), + description: z.string().describe('What the connector syncs.'), + version: z.string().describe('Connector version.'), + auth: z + .discriminatedUnion('mode', [ + z.object({ + mode: z.literal('oauth').describe('Authenticates with an OAuth credential.'), + provider: z.string().describe('OAuth service the credential must authenticate.'), + requiredScopes: z + .array(z.string()) + .optional() + .describe('Scopes the credential must carry.'), + }), + z.object({ + mode: z.literal('apiKey').describe('Authenticates with a stored API key.'), + label: z.string().optional().describe('Label shown above the key field.'), + placeholder: z.string().optional().describe('Placeholder shown in the key field.'), + optional: z + .boolean() + .describe( + 'Whether the key may be left blank, for a source reachable without authentication.' + ), + }), + ]) + .describe('How the connector authenticates against its source.'), + configFields: z + .array(v2ConnectorConfigFieldSchema) + .describe('Fields that make up the connector’s `sourceConfig`.'), + supportsIncrementalSync: z + .boolean() + .describe('Whether syncs after the first fetch only what changed.'), + tagDefinitions: z + .array( + z.object({ + id: z.string().describe('Semantic tag identifier the connector populates.'), + displayName: z.string().describe('Human-readable tag name.'), + fieldType: z + .enum(['text', 'number', 'date', 'boolean']) + .describe('Value type, which decides the tag slot pool it draws from.'), + }) + ) + .describe('Tags this connector writes onto the documents it syncs.'), + }) + .meta({ + id: 'V2ConnectorType', + title: 'Connector type', + description: 'A knowledge-base connector type and the configuration it accepts.', + }) +export type V2ConnectorType = z.output + +/** A code-defined table enrichment. */ +export const v2EnrichmentSchema = z + .object({ + id: z.string().describe('Enrichment identifier.'), + name: z.string().describe('Display name.'), + description: z.string().describe('What the enrichment fills in.'), + inputs: z + .array( + z.object({ + id: z.string().describe('Key the value is supplied under.'), + name: z.string().describe('Human-readable label.'), + type: z.enum(['string', 'number', 'boolean']).describe('Value type.'), + required: z.boolean().optional().describe('Whether the input must be mapped.'), + description: z.string().optional().describe('What the input means.'), + }) + ) + .describe('Per-row inputs the enrichment needs, each mapped to a table column.'), + outputs: z + .array( + z.object({ + id: z.string().describe('Key the value is returned under.'), + name: z.string().describe('Default column name.'), + type: columnTypeSchema.describe('Table column type the value is stored as.'), + }) + ) + .describe('Values the enrichment produces, each becoming a table column.'), + providers: z + .array( + z.object({ + id: z.string().describe('Provider identifier.'), + label: z.string().describe('Human-readable provider name.'), + toolId: z + .string() + .describe( + 'Built-in tool the provider runs. Resolve it with `GET /api/v2/tools/{toolId}`.' + ), + }) + ) + .describe( + 'Data sources in the order they are attempted. The first provider to return a non-empty result fills the cell, so this order is behavior rather than presentation.' + ), + }) + .meta({ + id: 'V2Enrichment', + title: 'Enrichment', + description: 'A code-defined table enrichment and its provider cascade.', + }) +export type V2Enrichment = z.output + +export const v2BlockSortFields = ['id', 'name', 'category'] as const +export const v2ToolSortFields = ['id', 'name'] as const + +export const v2ListBlocksQuerySchema = catalogWorkspaceQuerySchema + .extend({ + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the block id, name, and description.' + ), + category: z + .enum(['blocks', 'tools', 'triggers']) + .optional() + .describe('Restrict to one toolbar category.'), + capability: z + .enum(['trigger']) + .optional() + .describe( + 'Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields.' + ), + source: z + .enum(['builtin', 'custom']) + .optional() + .describe('Restrict to shipped blocks or to this workspace’s deployed custom blocks.'), + ...v2SortFields(v2BlockSortFields, { sortBy: 'id', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum blocks to return per page.' }), + }) + .strict() +export type V2ListBlocksQuery = z.output + +export const v2ListToolsQuerySchema = catalogWorkspaceQuerySchema + .extend({ + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the tool id, name, and description.' + ), + hostedApiKey: z + .enum(['always', 'conditional', 'none']) + .optional() + .describe('Restrict to tools by how their API key is supplied.'), + oauthProvider: z + .string() + .trim() + .min(1, 'oauthProvider cannot be empty') + .max(255, 'oauthProvider must be at most 255 characters') + .optional() + .describe('Restrict to tools that authenticate against this OAuth service.'), + ...v2SortFields(v2ToolSortFields, { sortBy: 'id', sortOrder: 'asc' }), + ...v2PaginationFields({ description: 'Maximum tools to return per page.' }), + }) + .strict() +export type V2ListToolsQuery = z.output + +export const v2GetBlockParamsSchema = z.object({ + blockId: catalogIdSchema.describe('Block type identifier.'), +}) +export type V2GetBlockParams = z.output + +export const v2GetToolParamsSchema = z.object({ + toolId: catalogIdSchema.describe( + 'Tool identifier. An unversioned name resolves to the newest version, and the response echoes the resolved id.' + ), +}) +export type V2GetToolParams = z.output + +export const v2ListConnectorTypesQuerySchema = catalogWorkspaceQuerySchema + .extend({ + search: v2SearchSchema.describe('Case-insensitive substring match against the connector name.'), + }) + .strict() +export type V2ListConnectorTypesQuery = z.output + +export const v2ListEnrichmentsQuerySchema = catalogWorkspaceQuerySchema + .extend({ + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the enrichment name.' + ), + }) + .strict() +export type V2ListEnrichmentsQuery = z.output + +/** + * Block list, paginated by an opaque offset cursor rather than the keyset most + * v2 lists use — the same case as `GET /api/v2/skills`. The sequence merges the + * static code registry with the workspace’s deployed custom blocks, filters it + * against the caller’s visibility, and sorts it in memory, so there is no + * ordered SQL read for a keyset predicate to act on. + */ +export const v2ListBlocksContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/blocks', + query: v2ListBlocksQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2BlockSummarySchema) }, +}) + +export const v2GetBlockContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/blocks/[blockId]', + params: v2GetBlockParamsSchema, + query: catalogWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2BlockDetailSchema) }, +}) + +/** + * Tool list, paginated by the same offset cursor and for the same reason: the + * catalog is a code-defined id set narrowed against the caller’s workspace + * visibility entirely in memory. + */ +export const v2ListToolsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tools', + query: v2ListToolsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2ToolSummarySchema) }, +}) + +export const v2GetToolContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/tools/[toolId]', + params: v2GetToolParamsSchema, + query: catalogWorkspaceQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2ToolDetailSchema) }, +}) + +export const v2ListConnectorTypesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/connector-types', + query: v2ListConnectorTypesQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ConnectorTypeSchema, { paged: false }), + }, +}) + +export const v2ListEnrichmentsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/enrichments', + query: v2ListEnrichmentsQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2EnrichmentSchema, { paged: false }), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 50ebb34a817..ebc33164de2 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,3 +1,11 @@ +import { + v2GetBlockContract, + v2GetToolContract, + v2ListBlocksContract, + v2ListConnectorTypesContract, + v2ListEnrichmentsContract, + v2ListToolsContract, +} from '@/lib/api/contracts/v2/catalog' import { v2CreateCredentialConnectionContract, v2CreateServiceAccountCredentialContract, @@ -245,6 +253,7 @@ type ResourceTag = | 'Custom Tools' | 'Credentials' | 'Secrets' + | 'Catalog' function resourceOperation( tag: ResourceTag, @@ -277,6 +286,145 @@ function resourceOperation( } } +const BLOCK_SUMMARY_EXAMPLE = { + id: 'slack', + name: 'Slack', + description: 'Send messages and read channels in Slack.', + category: 'tools', + integrationType: 'communication', + source: 'builtin', + authMode: 'oauth', + triggerAllowed: true, + triggerCapable: true, + triggerIds: ['slack_webhook'], + toolIds: ['slack_message', 'slack_canvas_read'], + operationIds: ['send', 'read'], + preview: false, + docsLink: 'https://docs.sim.ai/tools/slack', + tags: ['messaging'], +} as const + +const BLOCK_DETAIL_EXAMPLE = { + ...BLOCK_SUMMARY_EXAMPLE, + inputSchema: [ + { + id: 'operation', + type: 'dropdown', + title: 'Operation', + required: true, + options: [ + { id: 'send', label: 'Send message' }, + { id: 'read', label: 'Read messages' }, + ], + }, + ], + operationInputSchema: { + send: [{ id: 'text', type: 'long-input', title: 'Message', required: true }], + }, + inputDefinitions: { + channel: { type: 'string', description: 'Channel to post into.' }, + }, + operations: { + send: { + toolId: 'slack_message', + toolName: 'Slack Send Message', + description: 'Send a message to a Slack channel.', + inputs: { text: { type: 'string', required: true, description: 'Message body.' } }, + outputs: { ts: { type: 'string', description: 'Message timestamp.' } }, + inputSchema: [{ id: 'text', type: 'long-input', title: 'Message', required: true }], + }, + }, + tools: [ + { + id: 'slack_message', + name: 'Slack Send Message', + description: 'Send a message to a Slack channel.', + version: '1.0.0', + hostedApiKey: 'none', + oauth: { required: true, provider: 'slack', requiredScopes: ['chat:write'] }, + params: { text: { type: 'string', required: true, description: 'Message body.' } }, + outputs: { ts: { type: 'string', description: 'Message timestamp.' } }, + }, + ], + triggers: [ + { + id: 'slack_webhook', + outputs: { text: { type: 'string', description: 'Message text.' } }, + configFields: { + channels: { type: 'short-input', required: false, title: 'Channels' }, + }, + }, + ], + outputs: { ts: { type: 'string', description: 'Message timestamp.' } }, +} as const + +const TOOL_SUMMARY_EXAMPLE = { + id: 'slack_message', + name: 'Slack Send Message', + description: 'Send a message to a Slack channel.', + version: '1.0.0', + hostedApiKey: 'none', + oauth: { required: true, provider: 'slack', requiredScopes: ['chat:write'] }, +} as const + +const TOOL_DETAIL_EXAMPLE = { + ...TOOL_SUMMARY_EXAMPLE, + params: { + channel: { type: 'string', required: true, description: 'Channel ID to post into.' }, + text: { type: 'string', required: true, description: 'Message body.' }, + }, + outputs: { ts: { type: 'string', description: 'Message timestamp.' } }, +} as const + +const CONNECTOR_TYPE_EXAMPLE = { + connectorType: 'google_drive', + name: 'Google Drive', + description: 'Sync documents from a Google Drive folder.', + version: '1.0.0', + auth: { + mode: 'oauth', + provider: 'google-drive', + requiredScopes: ['https://www.googleapis.com/auth/drive.readonly'], + }, + configFields: [ + { + id: 'folderSelector', + title: 'Folder', + type: 'selector', + selectorKey: 'google-drive-folder', + mimeType: 'application/vnd.google-apps.folder', + mode: 'basic', + canonicalParamId: 'folderId', + required: true, + }, + { + id: 'manualFolderId', + title: 'Folder ID', + type: 'short-input', + placeholder: 'Enter the folder ID', + mode: 'advanced', + canonicalParamId: 'folderId', + }, + ], + supportsIncrementalSync: true, + tagDefinitions: [{ id: 'owner', displayName: 'Owner', fieldType: 'text' }], +} as const + +const ENRICHMENT_EXAMPLE = { + id: 'work-email', + name: 'Work email', + description: 'Find a person’s work email from their name and company.', + inputs: [ + { id: 'fullName', name: 'Full name', type: 'string', required: true }, + { id: 'domain', name: 'Company domain', type: 'string', required: true }, + ], + outputs: [{ id: 'email', name: 'Work email', type: 'string' }], + providers: [ + { id: 'hunter', label: 'Hunter', toolId: 'hunter_email_finder' }, + { id: 'pdl', label: 'People Data Labs', toolId: 'peopledatalabs_person_enrich' }, + ], +} as const + const declaredRoutes = [ defineOpenApiRoute( v2GetWorkspaceContract, @@ -1105,6 +1253,172 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListBlocksContract, + resourceOperation('Catalog', { + operationId: 'listBlocks', + summary: 'List Blocks', + description: + 'List the blocks available in a workspace, built-in and workspace-deployed alike, discriminated by `source`. Availability is caller-specific: the workspace’s integration allowlist, the organization’s revealed preview blocks, and the deployment’s allowlist all narrow the result. Use `capability=trigger` for the blocks that can start a workflow. Summaries name their tools and operations by id — resolve one with Get Block or Get Tool.', + errors: RESOURCE_ERRORS, + success: { description: 'A page of blocks available in the workspace.' }, + }), + { + query: documentedSchema( + v2ListBlocksContract.query, + 'ListBlocksQuery', + 'List blocks query', + 'Workspace scope, catalog filters, sort, and pagination.' + ), + response: documentedSchema( + v2ListBlocksContract.response.schema, + 'ListBlocksResponse', + 'List blocks response', + 'Blocks available in the workspace.', + [{ data: [BLOCK_SUMMARY_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2GetBlockContract, + resourceOperation('Catalog', { + operationId: 'getBlock', + summary: 'Get Block', + description: + 'Read one block’s full configuration shape: its fields and their conditions, its operations with the tool each runs, every tool’s parameters and outputs, and its triggers. A block this caller cannot see answers 404, identically to one that does not exist.', + errors: RESOURCE_ERRORS, + success: { description: 'The block.' }, + }), + { + params: documentedSchema( + v2GetBlockContract.params, + 'GetBlockParams', + 'Get block path parameters', + 'Block selected for retrieval.' + ), + query: documentedSchema( + v2GetBlockContract.query, + 'GetBlockQuery', + 'Get block query', + 'Workspace whose availability rules are applied.' + ), + response: documentedSchema( + v2GetBlockContract.response.schema, + 'GetBlockResponse', + 'Get block response', + 'One block with its fields, operations, tools, and triggers.', + [{ data: BLOCK_DETAIL_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2ListToolsContract, + resourceOperation('Catalog', { + operationId: 'listTools', + summary: 'List Tools', + description: + 'List the built-in tools available in a workspace. Built-in tools only: a workspace’s MCP tools are discovered per server on List MCP Server Tools, and its code-backed custom tools are on List Custom Tools. A tool is available when a block the caller can see exposes it, so the same allowlist and visibility rules as List Blocks apply.', + errors: RESOURCE_ERRORS, + success: { description: 'A page of built-in tools available in the workspace.' }, + }), + { + query: documentedSchema( + v2ListToolsContract.query, + 'ListToolsQuery', + 'List tools query', + 'Workspace scope, tool filters, sort, and pagination.' + ), + response: documentedSchema( + v2ListToolsContract.response.schema, + 'ListToolsResponse', + 'List tools response', + 'Built-in tools available in the workspace.', + [{ data: [TOOL_SUMMARY_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2GetToolContract, + resourceOperation('Catalog', { + operationId: 'getTool', + summary: 'Get Tool', + description: + 'Read one built-in tool’s declared parameters and outputs. An unversioned name resolves to the newest version — `gmail_send` answers with `gmail_send_v2` — and the returned `id` is always the resolved one, so a caller can see which version it got.', + errors: RESOURCE_ERRORS, + success: { description: 'The tool.' }, + }), + { + params: documentedSchema( + v2GetToolContract.params, + 'GetToolParams', + 'Get tool path parameters', + 'Tool selected for retrieval. An unversioned name resolves to the newest version.' + ), + query: documentedSchema( + v2GetToolContract.query, + 'GetToolQuery', + 'Get tool query', + 'Workspace whose availability rules are applied.' + ), + response: documentedSchema( + v2GetToolContract.response.schema, + 'GetToolResponse', + 'Get tool response', + 'One built-in tool with its parameters and outputs.', + [{ data: TOOL_DETAIL_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2ListConnectorTypesContract, + resourceOperation('Catalog', { + operationId: 'listConnectorTypes', + summary: 'List Connector Types', + description: `List every knowledge-base connector type and the source configuration each accepts. Two properties of a config field decide how its value is sent and are not inferable from the rest: a field with \`multi: true\` stores a \`string[]\` rather than a \`string\`, and a \`canonicalParamId\` links a picker field to a manual-entry field that write the SAME configuration key — send exactly one of the pair, keyed by \`canonicalParamId\` rather than by the field's own \`id\`. ${FULL_SET_LIST}`, + errors: RESOURCE_ERRORS, + success: { description: 'The connector-type catalog.' }, + }), + { + query: documentedSchema( + v2ListConnectorTypesContract.query, + 'ListConnectorTypesQuery', + 'List connector types query', + 'Workspace scope and optional connector-name search.' + ), + response: documentedSchema( + v2ListConnectorTypesContract.response.schema, + 'ListConnectorTypesResponse', + 'List connector types response', + 'Knowledge-base connector types and their configuration fields.', + [{ data: [CONNECTOR_TYPE_EXAMPLE], nextCursor: null }] + ), + } + ), + defineOpenApiRoute( + v2ListEnrichmentsContract, + resourceOperation('Catalog', { + operationId: 'listEnrichments', + summary: 'List Enrichments', + description: `List every code-defined table enrichment, the per-row inputs it needs, the columns it fills, and the providers it draws from. Providers are listed in the order they are attempted: the first to return a non-empty result fills the cell. ${FULL_SET_LIST}`, + errors: RESOURCE_ERRORS, + success: { description: 'The enrichment catalog.' }, + }), + { + query: documentedSchema( + v2ListEnrichmentsContract.query, + 'ListEnrichmentsQuery', + 'List enrichments query', + 'Workspace scope and optional enrichment-name search.' + ), + response: documentedSchema( + v2ListEnrichmentsContract.response.schema, + 'ListEnrichmentsResponse', + 'List enrichments response', + 'Table enrichments and their provider cascades.', + [{ data: [ENRICHMENT_EXAMPLE], nextCursor: null }] + ), + } + ), ] as const const routes = declaredRoutes.map(withRequestBodyErrors) @@ -1114,7 +1428,7 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ info: { title: 'Sim API v2 — Workspace Resources', description: - 'Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, and write-only secrets.', + 'Version 2 of the Sim REST API for workspace metadata, members, MCP servers, skills, custom tools, credentials, write-only secrets, and the block, tool, connector-type, and enrichment catalogs.', version: '2.0.0', contact: { name: 'Sim Support', @@ -1153,6 +1467,11 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ name: 'Secrets', description: 'Set and manage write-only workspace and personal secret values.', }, + { + name: 'Catalog', + description: + 'Discover the blocks, built-in tools, knowledge-base connector types, and table enrichments available in a workspace.', + }, ], security: V2_API_KEY_SECURITY, securitySchemes: V2_API_KEY_SECURITY_SCHEMES, diff --git a/apps/sim/lib/catalog/application/catalog-context.ts b/apps/sim/lib/catalog/application/catalog-context.ts new file mode 100644 index 00000000000..859e76cb1a6 --- /dev/null +++ b/apps/sim/lib/catalog/application/catalog-context.ts @@ -0,0 +1,94 @@ +import type { Principal } from '@sim/auth/principal' +import { type BlockVisibilityState, getBlockVisibility } from '@/lib/core/config/block-visibility' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { allowedIntegrationTypes, principalUserId } from '@/lib/integrations/principal-scope.server' +import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' +import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' +import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' +import type { BlockConfig } from '@/blocks/types' +import { isHiddenUnder } from '@/blocks/visibility/context' +import { withBlockVisibility } from '@/blocks/visibility/server-context' + +/** + * The per-caller, per-workspace state every catalog read is filtered through. + * + * A catalog looks like static reference data and is not. Four independent + * policies decide what a caller may see, and all four are resolved here so the + * six catalog use cases cannot answer differently. + */ +export interface CatalogGate { + /** Which unreleased blocks this viewer may see, and which shipped ones are kill-switched. */ + visibility: BlockVisibilityState + /** Lowercased block types the workspace permits, or `null` when unrestricted. */ + allowedIntegrations: ReadonlySet | null + /** Workflows this workspace's organization has deployed as blocks. */ + customBlockRows: Awaited> +} + +/** Loads the canonical workspace, concealing one the caller cannot reach as absent. */ +export async function loadCatalogWorkspaceContext( + workspaceId: string +): Promise { + const context = await loadActiveWorkspaceApplicationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +/** Resolves every policy that narrows the catalog for this caller and workspace. */ +export async function resolveCatalogGate( + principal: Principal, + context: ActiveWorkspaceApplicationContext +): Promise { + const userId = principalUserId(principal) + const [allowedIntegrations, visibility, customBlockRows] = await Promise.all([ + allowedIntegrationTypes(principal, context.workspaceId), + getBlockVisibility({ + ...(userId ? { userId } : {}), + ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), + }), + listCustomBlocksWithInputsForWorkspace(context.workspaceId), + ]) + return { allowedIntegrations, visibility, customBlockRows } +} + +/** + * Whether this caller may see a block at all. + * + * THE single predicate for the block catalog: the list filters with it and the + * detail route 404s on it. Applying a weaker rule to the detail route would let + * a caller enumerate unrevealed preview blocks one id at a time. + */ +export function isBlockVisibleToCaller(block: BlockConfig, gate: CatalogGate): boolean { + if (block.hideFromToolbar) return false + if (isHiddenUnder(gate.visibility, block)) return false + if (!isIntegrationDeploymentAvailableForVisibility(block.type, gate.visibility)) return false + return isBlockTypeAllowed(block.type, gate) +} + +/** Whether the workspace's permission-group allowlist admits a block type. */ +export function isBlockTypeAllowed(blockType: string, gate: CatalogGate): boolean { + if (gate.allowedIntegrations === null) return true + if (isBlockTypeAccessControlExempt(blockType)) return true + return gate.allowedIntegrations.has(blockType.toLowerCase()) +} + +/** + * Runs `read` with the gate's block scope established. + * + * `getAllBlocks`/`getBlock` are synchronous and resolve both the viewer's + * visibility projection and the workspace's custom blocks from + * AsyncLocalStorage. Outside this scope the visibility resolver returns `null`, + * which is fail-closed for unreleased blocks but does NOT apply the kill switch + * — a disabled shipped block would still be listed. The two scopes are + * independent and nest in either order. + */ +export function withCatalogBlockScope(gate: CatalogGate, read: () => Promise): Promise { + return withBlockVisibility(gate.visibility, () => + withCustomBlockOverlay(gate.customBlockRows, read) + ) +} diff --git a/apps/sim/lib/catalog/application/catalog-page.ts b/apps/sim/lib/catalog/application/catalog-page.ts new file mode 100644 index 00000000000..7e324ffdd81 --- /dev/null +++ b/apps/sim/lib/catalog/application/catalog-page.ts @@ -0,0 +1,76 @@ +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** + * Shared search, sort, and paging for the catalog reads. + * + * Every catalog list is a code-defined set narrowed in memory rather than an + * ordered SQL read, so all three steps happen here and all three are shared — + * two lists sorting the same field differently would make their cursors + * describe different sequences under the same name. + */ + +/** + * Normalizes a search term, rejecting one that is present but blank. + * + * The contracts already trim and reject an empty term, so this is the guard for + * a non-HTTP caller: a blank search that silently matched everything would be a + * filter the caller believes applied and did not. + */ +export function normalizeCatalogSearch(search: string | undefined): string | undefined { + if (search === undefined) return undefined + const normalized = search.trim().toLowerCase() + if (!normalized) throw new OrchestrationError('validation', 'search cannot be empty') + return normalized +} + +/** Whether any of a resource's searchable fields contains the term. */ +export function matchesCatalogSearch( + term: string | undefined, + ...fields: Array +): boolean { + if (term === undefined) return true + return fields.some((field) => field?.toLowerCase().includes(term)) +} + +/** + * Sorts a copy by one string field, breaking ties on `id`. + * + * The tie-break is what makes an offset cursor sound: two entries comparing + * equal on the sort field must still hold a fixed order, or the position a + * cursor names moves between requests. `id` is unique across every catalog, so + * it fully orders each one. + */ +export function sortCatalogEntries( + entries: readonly T[], + select: (entry: T) => string, + sortOrder: V2SortOrder +): T[] { + const direction = sortOrder === 'desc' ? -1 : 1 + return [...entries].sort((left, right) => { + const compared = select(left).localeCompare(select(right)) + if (compared !== 0) return compared * direction + return left.id.localeCompare(right.id) * direction + }) +} + +export interface CatalogPage { + entries: T[] + offset: number + limit: number + hasMore: boolean +} + +/** Takes one page out of an ordered sequence, reporting whether more remain. */ +export function takeCatalogPage( + entries: readonly T[], + offset: number, + limit: number +): CatalogPage { + return { + entries: entries.slice(offset, offset + limit), + offset, + limit, + hasMore: offset + limit < entries.length, + } +} diff --git a/apps/sim/lib/catalog/application/catalog-reads.test.ts b/apps/sim/lib/catalog/application/catalog-reads.test.ts new file mode 100644 index 00000000000..8224f00430b --- /dev/null +++ b/apps/sim/lib/catalog/application/catalog-reads.test.ts @@ -0,0 +1,434 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + allowedIntegrationTypes: vi.fn(), + getBlockVisibility: vi.fn(), + listCustomBlocks: vi.fn(), + isDeploymentAvailable: vi.fn(), + recordAudit: vi.fn(), + getAllBlocks: vi.fn(), + getBlock: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: mocks.recordAudit, + AuditAction: {}, + AuditResourceType: {}, +})) + +vi.mock('@/lib/integrations/principal-scope.server', () => ({ + allowedIntegrationTypes: mocks.allowedIntegrationTypes, + principalUserId: (principal: { kind: string; userId?: string }) => + principal.kind === 'session' || principal.kind === 'personal_api_key' + ? principal.userId + : undefined, +})) + +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mocks.getBlockVisibility, +})) + +vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ + listCustomBlocksWithInputsForWorkspace: mocks.listCustomBlocks, +})) + +vi.mock('@/lib/integrations/availability.server', () => ({ + isIntegrationDeploymentAvailableForVisibility: mocks.isDeploymentAvailable, +})) + +vi.mock('@/blocks/custom/server-overlay', () => ({ + withCustomBlockOverlay: (_rows: unknown, run: () => Promise) => run(), +})) + +vi.mock('@/blocks/visibility/server-context', () => ({ + withBlockVisibility: (_state: unknown, run: () => Promise) => run(), +})) + +vi.mock('@/blocks/registry', () => ({ + getAllBlocks: mocks.getAllBlocks, + getBlock: mocks.getBlock, + getBlockMeta: vi.fn(() => ({ tags: ['messaging'] })), +})) + +vi.mock('@/tools/metadata', () => ({ + getToolMetadata: (toolId: string) => + Object.hasOwn(TOOL_METADATA, toolId) ? TOOL_METADATA[toolId] : undefined, +})) + +vi.mock('@/tools/metadata-outputs', () => ({ + getToolOutputsMetadata: () => ({ ok: { type: 'boolean', description: 'Whether it worked.' } }), +})) + +vi.mock('@/tools/tool-ids', () => ({ + getToolIds: () => Object.freeze(Object.keys(TOOL_METADATA)), + resolveToolId: (toolId: string) => toolId, +})) + +import { getCatalogBlock } from '@/lib/catalog/application/get-block' +import { getCatalogTool } from '@/lib/catalog/application/get-tool' +import { listCatalogBlocks } from '@/lib/catalog/application/list-blocks' +import { listCatalogTools } from '@/lib/catalog/application/list-tools' +import type { BlockConfig } from '@/blocks/types' + +const TOOL_METADATA: Record> = { + slack_message: { + id: 'slack_message', + name: 'Slack Send Message', + description: 'Send a message.', + version: '1.0.0', + params: { text: { type: 'string', required: true } }, + hostedApiKey: 'none', + oauth: { required: true, provider: 'slack' }, + }, + preview_call: { + id: 'preview_call', + name: 'Preview Call', + description: 'Call the preview service.', + version: '1.0.0', + params: {}, + hostedApiKey: 'always', + }, +} + +const WORKSPACE_ID = 'workspace-1' + +const workspaceContext = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const session: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } +const workspaceKey: WorkspaceApiKeyPrincipal = { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} + +function block(overrides: Partial & { type: string }): BlockConfig { + return { + name: overrides.type, + description: `${overrides.type} block`, + category: 'tools', + bgColor: '#000000', + icon: (() => null) as unknown as BlockConfig['icon'], + subBlocks: [], + tools: { access: [] }, + inputs: {}, + outputs: {}, + ...overrides, + } as BlockConfig +} + +const slackBlock = block({ + type: 'slack', + name: 'Slack', + description: 'Send messages in Slack.', + triggerAllowed: true, + subBlocks: [ + { + id: 'operation', + type: 'dropdown', + title: 'Operation', + options: [{ id: 'send', label: 'Send message' }], + }, + { + id: 'text', + type: 'long-input', + title: 'Message', + condition: { field: 'operation', value: 'send' }, + }, + ], + tools: { + access: ['slack_message'], + config: { tool: () => 'slack_message' }, + }, +}) +const notionBlock = block({ type: 'notion', name: 'Notion', description: 'Read Notion pages.' }) +const previewBlock = block({ + type: 'preview_thing', + name: 'Preview thing', + preview: true, + tools: { access: ['preview_call'] }, +}) +const customBlock = block({ + type: 'custom_block_reports', + name: 'Reports', + description: 'Run the reports workflow.', +}) + +const NOTHING_GATED = { + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), +} + +const listInput = { + workspaceId: WORKSPACE_ID, + sortBy: 'id' as const, + sortOrder: 'asc' as const, + offset: 0, + limit: 50, +} + +describe('catalog block and tool reads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.allowedIntegrationTypes.mockResolvedValue(null) + mocks.getBlockVisibility.mockResolvedValue(NOTHING_GATED) + mocks.listCustomBlocks.mockResolvedValue([]) + mocks.isDeploymentAvailable.mockReturnValue(true) + mocks.getAllBlocks.mockReturnValue([slackBlock, notionBlock, customBlock]) + mocks.getBlock.mockImplementation((type: string) => + [slackBlock, notionBlock, previewBlock, customBlock].find((entry) => entry.type === type) + ) + }) + + it('lists blocks for a session principal and records no audit', async () => { + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + + expect(result.entries.map((entry) => entry.id)).toEqual([ + 'custom_block_reports', + 'notion', + 'slack', + ]) + expect(result.hasMore).toBe(false) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('accepts a workspace API key, which has no user for permission groups to key on', async () => { + const result = await listCatalogBlocks.execute({ principal: workspaceKey, input: listInput }) + + expect(result.entries).toHaveLength(3) + expect(mocks.allowedIntegrationTypes).toHaveBeenCalledWith(workspaceKey, WORKSPACE_ID) + expect(mocks.getBlockVisibility).toHaveBeenCalledWith({ orgId: 'org-1' }) + }) + + it('resolves block visibility for the acting user and their organization', async () => { + await listCatalogBlocks.execute({ principal: session, input: listInput }) + + expect(mocks.getBlockVisibility).toHaveBeenCalledWith({ userId: 'user-1', orgId: 'org-1' }) + }) + + it('discriminates a workspace custom block from a shipped one', async () => { + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + + const sources = Object.fromEntries(result.entries.map((entry) => [entry.id, entry.source])) + expect(sources).toEqual({ + custom_block_reports: 'custom', + notion: 'builtin', + slack: 'builtin', + }) + }) + + it('answers not found for a workspace the caller cannot reach', async () => { + mocks.loadWorkspace.mockResolvedValue(null) + + await expect( + listCatalogBlocks.execute({ principal: session, input: listInput }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workspace not found' }) + }) + + it('propagates an integration-allowlist infrastructure failure instead of concealing it', async () => { + mocks.allowedIntegrationTypes.mockRejectedValue(new Error('permission store unavailable')) + + await expect( + listCatalogBlocks.execute({ principal: session, input: listInput }) + ).rejects.toThrow('permission store unavailable') + }) + + it('hides an unrevealed preview block from the list and from its detail read', async () => { + mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) + + await expect( + getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'preview_thing' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Block not found' }) + }) + + it('reveals a preview block once the visibility document names it', async () => { + mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) + mocks.getBlockVisibility.mockResolvedValue({ + revealed: new Set(['preview_thing']), + disabled: new Set(), + previewTagged: new Set(['preview_thing']), + }) + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).toContain('preview_thing') + }) + + it('drops a kill-switched block from the list and 404s its detail', async () => { + mocks.getBlockVisibility.mockResolvedValue({ + revealed: new Set(), + disabled: new Set(['notion']), + previewTagged: new Set(), + }) + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).not.toContain('notion') + + await expect( + getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'notion' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Block not found' }) + }) + + it('drops a block the permission-group allowlist excludes, from list and detail alike', async () => { + mocks.allowedIntegrationTypes.mockResolvedValue(new Set(['slack'])) + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) + + await expect( + getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'notion' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('drops a block this deployment does not ship', async () => { + mocks.isDeploymentAvailable.mockImplementation((type: string) => type !== 'notion') + + const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(result.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'slack']) + }) + + it('narrows to trigger-capable blocks without a second endpoint', async () => { + const result = await listCatalogBlocks.execute({ + principal: session, + input: { ...listInput, capability: 'trigger' }, + }) + + expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) + }) + + it('rejects a blank search rather than silently matching everything', async () => { + await expect( + listCatalogBlocks.execute({ principal: session, input: { ...listInput, search: ' ' } }) + ).rejects.toMatchObject({ code: 'validation', message: 'search cannot be empty' }) + }) + + it('pages the sorted sequence and reports whether more remain', async () => { + const first = await listCatalogBlocks.execute({ + principal: session, + input: { ...listInput, limit: 2 }, + }) + expect(first.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'notion']) + expect(first.hasMore).toBe(true) + + const second = await listCatalogBlocks.execute({ + principal: session, + input: { ...listInput, limit: 2, offset: 2 }, + }) + expect(second.entries.map((entry) => entry.id)).toEqual(['slack']) + expect(second.hasMore).toBe(false) + }) + + it('reads one block with its operations and tools resolved from metadata', async () => { + const { block: detail } = await getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'slack' }, + }) + + expect(detail.id).toBe('slack') + expect(detail.tools.map((tool) => tool.id)).toEqual(['slack_message']) + expect(detail.tools[0].params).toEqual({ text: { type: 'string', required: true } }) + + /** + * The operation's inputs come from the generated tool metadata, not the + * executable registry — that substitution is the whole point of the shared + * projection, so it is pinned rather than assumed. + */ + expect(detail.operationIds).toEqual(['send']) + expect(detail.operations.send.toolId).toBe('slack_message') + expect(detail.operations.send.inputs).toEqual({ text: { type: 'string', required: true } }) + expect(detail.operations.send.outputs).toEqual({ + ok: { type: 'boolean', description: 'Whether it worked.' }, + }) + expect(detail.operationInputSchema.send.map((field) => field.id)).toEqual(['text']) + }) + + it('lists only the tools a visible block exposes', async () => { + mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) + + const result = await listCatalogTools.execute({ principal: session, input: listInput }) + + expect(result.entries.map((entry) => entry.id)).toEqual(['slack_message']) + }) + + it('filters tools by how their API key is supplied', async () => { + mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) + mocks.getBlockVisibility.mockResolvedValue({ + revealed: new Set(['preview_thing']), + disabled: new Set(), + previewTagged: new Set(), + }) + + const hosted = await listCatalogTools.execute({ + principal: session, + input: { ...listInput, hostedApiKey: 'always' }, + }) + expect(hosted.entries.map((entry) => entry.id)).toEqual(['preview_call']) + + const byProvider = await listCatalogTools.execute({ + principal: session, + input: { ...listInput, oauthProvider: 'SLACK' }, + }) + expect(byProvider.entries.map((entry) => entry.id)).toEqual(['slack_message']) + }) + + it('answers not found for an unknown tool and for one no visible block exposes', async () => { + await expect( + getCatalogTool.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, toolId: 'nope_missing' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Tool not found' }) + + mocks.getAllBlocks.mockReturnValue([slackBlock]) + await expect( + getCatalogTool.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, toolId: 'preview_call' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Tool not found' }) + }) + + it('reads one tool with its params and outputs', async () => { + const { tool } = await getCatalogTool.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, toolId: 'slack_message' }, + }) + + expect(tool.id).toBe('slack_message') + expect(tool.outputs).toEqual({ ok: { type: 'boolean', description: 'Whether it worked.' } }) + }) +}) diff --git a/apps/sim/lib/catalog/application/get-block.ts b/apps/sim/lib/catalog/application/get-block.ts new file mode 100644 index 00000000000..0be39fd6e80 --- /dev/null +++ b/apps/sim/lib/catalog/application/get-block.ts @@ -0,0 +1,48 @@ +import { + isBlockVisibleToCaller, + loadCatalogWorkspaceContext, + resolveCatalogGate, + withCatalogBlockScope, +} from '@/lib/catalog/application/catalog-context' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { type CatalogBlockDetail, projectBlockDetail } from '@/lib/catalog/projection/block-detail' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBlock } from '@/blocks/registry' + +export interface GetCatalogBlockInput { + workspaceId: string + blockId: string +} + +export interface GetCatalogBlockResult { + block: CatalogBlockDetail +} + +/** + * One block's full authoring shape. + * + * Every filter the list applies also produces a 404 here — an unknown type, a + * block hidden from the toolbar, an unrevealed preview block, a kill-switched + * one, a type this deployment does not ship, and one the workspace's permission + * groups exclude all answer identically. Anything softer would let a caller + * enumerate unrevealed blocks one id at a time. + */ +export const getCatalogBlock = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.readBlock, + resolveContext: ({ input }: { input: GetCatalogBlockInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const gate = await resolveCatalogGate(principal, context) + + const detail = await withCatalogBlockScope(gate, async () => { + const block = getBlock(input.blockId) + if (!block || !isBlockVisibleToCaller(block, gate)) return null + return projectBlockDetail(block) + }) + + if (!detail) throw new OrchestrationError('not_found', 'Block not found') + return { block: detail } + }, +}) diff --git a/apps/sim/lib/catalog/application/get-tool.ts b/apps/sim/lib/catalog/application/get-tool.ts new file mode 100644 index 00000000000..530c7221b7c --- /dev/null +++ b/apps/sim/lib/catalog/application/get-tool.ts @@ -0,0 +1,47 @@ +import { + loadCatalogWorkspaceContext, + resolveCatalogGate, +} from '@/lib/catalog/application/catalog-context' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { resolveVisibleToolIds } from '@/lib/catalog/application/tool-scope' +import { type CatalogToolDetail, projectToolDetail } from '@/lib/catalog/projection/tool' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveToolId } from '@/tools/tool-ids' + +export interface GetCatalogToolInput { + workspaceId: string + toolId: string +} + +export interface GetCatalogToolResult { + tool: CatalogToolDetail +} + +/** + * One built-in tool's parameters and outputs. + * + * An unversioned name resolves to the newest version exactly as execution does, + * and the returned `id` is the resolved one so a caller can see which version + * answered. A tool the workspace's blocks do not expose answers 404 rather than + * 403, for the same enumeration reason as the block detail read. + */ +export const getCatalogTool = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.readTool, + resolveContext: ({ input }: { input: GetCatalogToolInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const resolvedToolId = resolveToolId(input.toolId) + const tool = projectToolDetail(resolvedToolId) + if (!tool) throw new OrchestrationError('not_found', 'Tool not found') + + const gate = await resolveCatalogGate(principal, context) + const visibleToolIds = await resolveVisibleToolIds(gate) + if (!visibleToolIds.has(resolvedToolId)) { + throw new OrchestrationError('not_found', 'Tool not found') + } + + return { tool } + }, +}) diff --git a/apps/sim/lib/catalog/application/list-blocks.ts b/apps/sim/lib/catalog/application/list-blocks.ts new file mode 100644 index 00000000000..bbf6df05bef --- /dev/null +++ b/apps/sim/lib/catalog/application/list-blocks.ts @@ -0,0 +1,91 @@ +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { + isBlockVisibleToCaller, + loadCatalogWorkspaceContext, + resolveCatalogGate, + withCatalogBlockScope, +} from '@/lib/catalog/application/catalog-context' +import { + type CatalogPage, + matchesCatalogSearch, + normalizeCatalogSearch, + sortCatalogEntries, + takeCatalogPage, +} from '@/lib/catalog/application/catalog-page' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { + type CatalogBlockSummary, + projectBlockSummary, +} from '@/lib/catalog/projection/block-summary' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { getAllBlocks } from '@/blocks/registry' + +export interface ListCatalogBlocksInput { + workspaceId: string + search?: string + category?: 'blocks' | 'tools' | 'triggers' + capability?: 'trigger' + source?: 'builtin' | 'custom' + sortBy: 'id' | 'name' | 'category' + sortOrder: V2SortOrder + offset: number + limit: number +} + +export type ListCatalogBlocksResult = CatalogPage + +const SORT_FIELDS: Record< + ListCatalogBlocksInput['sortBy'], + (block: CatalogBlockSummary) => string +> = { + id: (block) => block.id, + name: (block) => block.name, + category: (block) => block.category, +} + +function matchesFilters(block: CatalogBlockSummary, input: ListCatalogBlocksInput): boolean { + if (input.category && block.category !== input.category) return false + if (input.capability === 'trigger' && !block.triggerCapable) return false + if (input.source && block.source !== input.source) return false + return true +} + +/** + * The blocks this caller may place in this workspace. + * + * Built-in and custom blocks are one list on purpose: a workflow references + * either by `type`, so "what may I place?" must be answerable in one call. The + * `source` field tells them apart, and `capability=trigger` narrows to the + * blocks that can start a workflow rather than needing a second endpoint. + * + * No audit is projected — reading a catalog is not a semantic event, and no + * shipped v2 read records one. + */ +export const listCatalogBlocks = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.listBlocks, + resolveContext: ({ input }: { input: ListCatalogBlocksInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const search = normalizeCatalogSearch(input.search) + const gate = await resolveCatalogGate(principal, context) + + const summaries = await withCatalogBlockScope(gate, async () => + getAllBlocks() + .filter((block) => isBlockVisibleToCaller(block, gate)) + .map(projectBlockSummary) + ) + + const filtered = summaries.filter( + (block) => + matchesFilters(block, input) && + matchesCatalogSearch(search, block.id, block.name, block.description) + ) + + return takeCatalogPage( + sortCatalogEntries(filtered, SORT_FIELDS[input.sortBy], input.sortOrder), + input.offset, + input.limit + ) + }, +}) diff --git a/apps/sim/lib/catalog/application/list-connector-types.ts b/apps/sim/lib/catalog/application/list-connector-types.ts new file mode 100644 index 00000000000..5bf924cc2e2 --- /dev/null +++ b/apps/sim/lib/catalog/application/list-connector-types.ts @@ -0,0 +1,47 @@ +import { loadCatalogWorkspaceContext } from '@/lib/catalog/application/catalog-context' +import { + matchesCatalogSearch, + normalizeCatalogSearch, +} from '@/lib/catalog/application/catalog-page' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { + type CatalogConnectorType, + projectConnectorType, +} from '@/lib/catalog/projection/connector-type' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' + +export interface ListCatalogConnectorTypesInput { + workspaceId: string + search?: string +} + +export interface ListCatalogConnectorTypesResult { + connectorTypes: CatalogConnectorType[] +} + +/** + * Every knowledge-base connector type, in registry order. + * + * Returned as one page: the set is bounded by the code-defined connector + * registry rather than by workspace content, exactly as the credential-provider + * catalog is. Nothing gates a connector type per workspace today, but the + * operation is still workspace-scoped — retrofitting a required parameter onto + * a shipped v2 contract is a breaking change, and one parameter now is cheap. + */ +export const listCatalogConnectorTypes = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.listConnectorTypes, + resolveContext: ({ input }: { input: ListCatalogConnectorTypesInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ input }): Promise => { + const search = normalizeCatalogSearch(input.search) + const connectorTypes: CatalogConnectorType[] = [] + for (const [connectorType, meta] of Object.entries(CONNECTOR_META_REGISTRY)) { + const projected = projectConnectorType(connectorType, meta) + if (!matchesCatalogSearch(search, projected.name)) continue + connectorTypes.push(projected) + } + return { connectorTypes } + }, +}) diff --git a/apps/sim/lib/catalog/application/list-enrichments.ts b/apps/sim/lib/catalog/application/list-enrichments.ts new file mode 100644 index 00000000000..f2d73d9e47e --- /dev/null +++ b/apps/sim/lib/catalog/application/list-enrichments.ts @@ -0,0 +1,40 @@ +import { loadCatalogWorkspaceContext } from '@/lib/catalog/application/catalog-context' +import { + matchesCatalogSearch, + normalizeCatalogSearch, +} from '@/lib/catalog/application/catalog-page' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { type CatalogEnrichment, projectEnrichment } from '@/lib/catalog/projection/enrichment' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { ALL_ENRICHMENTS } from '@/enrichments/registry' + +export interface ListCatalogEnrichmentsInput { + workspaceId: string + search?: string +} + +export interface ListCatalogEnrichmentsResult { + enrichments: CatalogEnrichment[] +} + +/** + * Every table enrichment, in catalog order. + * + * One page, for the same reason as the connector types: the set is the + * code-defined enrichment registry, bounded by construction rather than by a + * caller's page size. + */ +export const listCatalogEnrichments = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.listEnrichments, + resolveContext: ({ input }: { input: ListCatalogEnrichmentsInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ input }): Promise => { + const search = normalizeCatalogSearch(input.search) + return { + enrichments: ALL_ENRICHMENTS.map(projectEnrichment).filter((enrichment) => + matchesCatalogSearch(search, enrichment.name) + ), + } + }, +}) diff --git a/apps/sim/lib/catalog/application/list-registries.test.ts b/apps/sim/lib/catalog/application/list-registries.test.ts new file mode 100644 index 00000000000..8cd9003e140 --- /dev/null +++ b/apps/sim/lib/catalog/application/list-registries.test.ts @@ -0,0 +1,111 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: mocks.recordAudit, + AuditAction: {}, + AuditResourceType: {}, +})) + +import { listCatalogConnectorTypes } from '@/lib/catalog/application/list-connector-types' +import { listCatalogEnrichments } from '@/lib/catalog/application/list-enrichments' + +const WORKSPACE_ID = 'workspace-1' +const session: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } + +describe('connector-type and enrichment catalogs', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.resolvePermission.mockResolvedValue('read') + }) + + it('returns the whole connector-type registry and records no audit', async () => { + const { connectorTypes } = await listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID }, + }) + + expect(connectorTypes.length).toBeGreaterThan(10) + expect(connectorTypes.every((entry) => typeof entry.connectorType === 'string')).toBe(true) + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('publishes the multi and canonical-pair config properties a caller cannot infer', async () => { + const { connectorTypes } = await listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID }, + }) + + const fields = connectorTypes.flatMap((entry) => entry.configFields) + expect(fields.some((field) => field.multi === true)).toBe(true) + expect(fields.some((field) => typeof field.canonicalParamId === 'string')).toBe(true) + expect(fields.every((field) => !Object.hasOwn(field, 'icon'))).toBe(true) + }) + + it('searches connector names case-insensitively', async () => { + const { connectorTypes } = await listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, search: 'noTIon' }, + }) + + expect(connectorTypes.map((entry) => entry.connectorType)).toEqual(['notion']) + }) + + it('answers not found for a workspace the caller cannot reach', async () => { + mocks.loadWorkspace.mockResolvedValue(null) + + await expect( + listCatalogEnrichments.execute({ principal: session, input: { workspaceId: WORKSPACE_ID } }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Workspace not found' }) + }) + + it('returns every enrichment with its provider cascade in declared order', async () => { + const { enrichments } = await listCatalogEnrichments.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID }, + }) + + expect(enrichments.length).toBeGreaterThan(0) + for (const enrichment of enrichments) { + expect(enrichment.providers.length).toBeGreaterThan(0) + for (const provider of enrichment.providers) { + expect(typeof provider.toolId).toBe('string') + expect(Object.keys(provider).sort()).toEqual(['id', 'label', 'toolId']) + } + } + }) + + it('rejects a blank search rather than silently matching everything', async () => { + await expect( + listCatalogEnrichments.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, search: ' ' }, + }) + ).rejects.toMatchObject({ code: 'validation', message: 'search cannot be empty' }) + }) +}) diff --git a/apps/sim/lib/catalog/application/list-tools.ts b/apps/sim/lib/catalog/application/list-tools.ts new file mode 100644 index 00000000000..b9a1ee8b647 --- /dev/null +++ b/apps/sim/lib/catalog/application/list-tools.ts @@ -0,0 +1,75 @@ +import type { V2SortOrder } from '@/lib/api/contracts/v2/shared' +import { + loadCatalogWorkspaceContext, + resolveCatalogGate, +} from '@/lib/catalog/application/catalog-context' +import { + type CatalogPage, + matchesCatalogSearch, + normalizeCatalogSearch, + sortCatalogEntries, + takeCatalogPage, +} from '@/lib/catalog/application/catalog-page' +import { catalogOperations } from '@/lib/catalog/application/operations' +import { resolveVisibleToolIds } from '@/lib/catalog/application/tool-scope' +import { type CatalogToolSummary, projectToolSummaryById } from '@/lib/catalog/projection/tool' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import type { HostedApiKeySupport } from '@/tools/hosted-api-key' +import { getToolIds } from '@/tools/tool-ids' + +export interface ListCatalogToolsInput { + workspaceId: string + search?: string + hostedApiKey?: HostedApiKeySupport + oauthProvider?: string + sortBy: 'id' | 'name' + sortOrder: V2SortOrder + offset: number + limit: number +} + +export type ListCatalogToolsResult = CatalogPage + +const SORT_FIELDS: Record string> = { + id: (tool) => tool.id, + name: (tool) => tool.name, +} + +/** + * The built-in tools this caller may run in this workspace. + * + * Built-in tools only. A workspace's MCP tools are discovered live per server + * and live on `GET /api/v2/mcp-servers/{id}/tools`; its code-backed custom tools + * are a CRUD resource on `GET /api/v2/custom-tools`. Three resources with three + * lifecycles, deliberately not unioned. + */ +export const listCatalogTools = defineAuthorizedWorkspaceUseCase({ + operation: catalogOperations.listTools, + resolveContext: ({ input }: { input: ListCatalogToolsInput }) => + loadCatalogWorkspaceContext(input.workspaceId), + authorizationOptions: {}, + execute: async ({ principal, input, context }): Promise => { + const search = normalizeCatalogSearch(input.search) + const oauthProvider = input.oauthProvider?.trim().toLowerCase() + const gate = await resolveCatalogGate(principal, context) + const visibleToolIds = await resolveVisibleToolIds(gate) + + const summaries: CatalogToolSummary[] = [] + /** `getToolIds()` hands out a frozen array — read it, never reorder it in place. */ + for (const toolId of getToolIds()) { + if (!visibleToolIds.has(toolId)) continue + const summary = projectToolSummaryById(toolId) + if (!summary) continue + if (input.hostedApiKey && summary.hostedApiKey !== input.hostedApiKey) continue + if (oauthProvider && summary.oauth?.provider.toLowerCase() !== oauthProvider) continue + if (!matchesCatalogSearch(search, summary.id, summary.name, summary.description)) continue + summaries.push(summary) + } + + return takeCatalogPage( + sortCatalogEntries(summaries, SORT_FIELDS[input.sortBy], input.sortOrder), + input.offset, + input.limit + ) + }, +}) diff --git a/apps/sim/lib/catalog/application/operations.test.ts b/apps/sim/lib/catalog/application/operations.test.ts new file mode 100644 index 00000000000..29de0a99667 --- /dev/null +++ b/apps/sim/lib/catalog/application/operations.test.ts @@ -0,0 +1,56 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { catalogOperations } from '@/lib/catalog/application/operations' + +/** + * Operation metadata is executable policy, not documentation: it decides which + * principals reach the use case and at what role. Pinning it here makes + * widening any of the six a deliberate edit rather than a side effect. + */ +const EXPECTED_OPERATION_IDS = { + listBlocks: 'catalog.blocks.list', + readBlock: 'catalog.blocks.read', + listTools: 'catalog.tools.list', + readTool: 'catalog.tools.read', + listConnectorTypes: 'catalog.connector_types.list', + listEnrichments: 'catalog.enrichments.list', +} as const + +describe('catalogOperations', () => { + it('declares exactly the six catalog reads under their published ids', () => { + expect(Object.keys(catalogOperations).sort()).toEqual( + Object.keys(EXPECTED_OPERATION_IDS).sort() + ) + for (const [key, id] of Object.entries(EXPECTED_OPERATION_IDS)) { + expect(catalogOperations[key as keyof typeof catalogOperations].id).toBe(id) + } + }) + + it('keeps every catalog read at the read role with workspace keys allowed', () => { + for (const operation of Object.values(catalogOperations)) { + expect(operation.minimumRole, operation.id).toBe('read') + expect(operation.workspaceApiKey, operation.id).toBe('allow') + expect([...operation.principalKinds].sort(), operation.id).toEqual([ + 'personal_api_key', + 'session', + 'workspace_api_key', + ]) + } + }) + + it('admits no delegated principal, because no delegated caller exists yet', () => { + for (const operation of Object.values(catalogOperations)) { + expect(operation.principalKinds, operation.id).not.toContain('delegated') + expect(operation.delegatedServices, operation.id).toBeUndefined() + } + }) + + it('freezes each operation so a caller cannot widen it at runtime', () => { + for (const operation of Object.values(catalogOperations)) { + expect(Object.isFrozen(operation), operation.id).toBe(true) + expect(Object.isFrozen(operation.principalKinds), operation.id).toBe(true) + } + }) +}) diff --git a/apps/sim/lib/catalog/application/operations.ts b/apps/sim/lib/catalog/application/operations.ts new file mode 100644 index 00000000000..198f2be778d --- /dev/null +++ b/apps/sim/lib/catalog/application/operations.ts @@ -0,0 +1,54 @@ +import { defineWorkspaceOperation } from '@/lib/core/application' + +/** + * Semantic operations for reading Sim's code-defined catalogs. + * + * All six share the policy of `credentials.providers.list`: a workspace-scoped + * read at the `read` role, reachable by a workspace API key. That is the exact + * shipped precedent for "a code-defined registry whose availability is evaluated + * per workspace", and these catalogs are the same thing — filtered by the + * workspace's integration allowlist, the organization's revealed preview blocks, + * the deployment's allowlist, and the workspace's own deployed custom blocks. + * + * No `delegated` principal kind: Copilot reads these catalogs through its own + * tools, which share the projection rather than the use case, so adding one + * would widen authorization for a caller that does not exist. + */ +export const catalogOperations = { + listBlocks: defineWorkspaceOperation({ + id: 'catalog.blocks.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + readBlock: defineWorkspaceOperation({ + id: 'catalog.blocks.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + listTools: defineWorkspaceOperation({ + id: 'catalog.tools.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + readTool: defineWorkspaceOperation({ + id: 'catalog.tools.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + listConnectorTypes: defineWorkspaceOperation({ + id: 'catalog.connector_types.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + listEnrichments: defineWorkspaceOperation({ + id: 'catalog.enrichments.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), +} as const diff --git a/apps/sim/lib/catalog/application/tool-scope.ts b/apps/sim/lib/catalog/application/tool-scope.ts new file mode 100644 index 00000000000..c7c5a0c344a --- /dev/null +++ b/apps/sim/lib/catalog/application/tool-scope.ts @@ -0,0 +1,31 @@ +import { + type CatalogGate, + isBlockVisibleToCaller, + withCatalogBlockScope, +} from '@/lib/catalog/application/catalog-context' +import { getAllBlocks } from '@/blocks/registry' +import { resolveToolId } from '@/tools/tool-ids' + +/** + * The built-in tools this caller may run in this workspace. + * + * A tool's availability is its owning block's: the permission-group allowlist, + * the preview-reveal state, and the deployment allowlist are all expressed + * against block types, so the catalog derives the tool set from the blocks that + * survive the gate rather than restating those policies against tool ids. + * + * A tool no visible block references is therefore absent, which is also the + * right answer for the handful of internal tools no block exposes — they are + * not caller-invokable, so publishing them would advertise an id that cannot be + * used. + */ +export function resolveVisibleToolIds(gate: CatalogGate): Promise> { + return withCatalogBlockScope(gate, async () => { + const toolIds = new Set() + for (const block of getAllBlocks()) { + if (!isBlockVisibleToCaller(block, gate)) continue + for (const toolId of block.tools?.access ?? []) toolIds.add(resolveToolId(toolId)) + } + return toolIds + }) +} diff --git a/apps/sim/lib/catalog/projection/block-detail.ts b/apps/sim/lib/catalog/projection/block-detail.ts new file mode 100644 index 00000000000..ce31f4947d3 --- /dev/null +++ b/apps/sim/lib/catalog/projection/block-detail.ts @@ -0,0 +1,432 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + actionSubBlocks, + type CatalogBlockSummary, + projectBlockSummary, + resolveOperationIds, +} from '@/lib/catalog/projection/block-summary' +import { + type CatalogSubBlock, + normalizeCondition, + projectSubBlock, +} from '@/lib/catalog/projection/subblock' +import { + type CatalogToolDetail, + type CatalogToolOutput, + type CatalogToolSummary, + projectToolDetail, +} from '@/lib/catalog/projection/tool' +import { isCustomBlockType } from '@/blocks/custom/build-config' +import { type BlockConfig, isHiddenFromDisplay, type SubBlockConfig } from '@/blocks/types' +import { getTrigger, isTriggerValid } from '@/triggers' +import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' + +const logger = createLogger('CatalogBlockProjection') + +/** + * Surface-neutral projection of one block's full authoring shape: its + * configuration fields, its per-operation inputs and outputs, its tools, and + * its triggers. + * + * Extracted from the Copilot `get_blocks_metadata` tool so the public catalog + * and the agent describe a block identically. Tool data comes from + * `@/tools/metadata` + `@/tools/metadata-outputs`; reading `@/tools/registry` + * for it is what used to make that tool's module graph 6,754 modules. + */ + +/** A block-level input definition, as declared on `BlockConfig.inputs`. */ +export interface CatalogInputDefinition { + type: string + description?: string + /** JSON-Schema-shaped structure for object and array params. Arbitrarily nested. */ + schema?: unknown +} + +/** A declared output of a block. */ +export interface CatalogBlockOutput { + type: string + description?: string +} + +/** One operation a block exposes, resolved to the tool that performs it. */ +export interface CatalogBlockOperation { + toolId?: string + toolName?: string + description?: string + /** Tool params plus operation-scoped block inputs, minus anything the block supplies itself. */ + inputs: Record + outputs: Record + /** The configuration fields that appear when this operation is selected. */ + inputSchema: CatalogSubBlock[] +} + +/** One trigger a block can run on. */ +export interface CatalogBlockTrigger { + id: string + outputs: Record + configFields: Record +} + +/** + * Projects a trigger's declared outputs. + * + * `TriggerOutput` is an open, self-nesting shape whose extra keys are nested + * outputs. Only the top level is published, with the same `type`/`description` + * projection every other output family gets — a caller reads a trigger's real + * payload from a run, not from a schema that cannot describe it faithfully. + */ +function projectTriggerOutputs( + outputs: Record | undefined +): Record { + const projected: Record = {} + for (const [key, definition] of Object.entries(outputs ?? {})) { + if (!definition || typeof definition !== 'object') continue + const entry: CatalogBlockOutput = { type: String(definition.type ?? 'any') } + if (typeof definition.description === 'string') entry.description = definition.description + projected[key] = entry + } + return projected +} + +/** One configurable field of a trigger. */ +export interface CatalogTriggerConfigField { + type: string + required: boolean + title?: string + description?: string + placeholder?: string + default?: unknown + options?: { id: string; label: string }[] + condition?: CatalogSubBlock['condition'] +} + +/** The full authoring shape of one block. */ +export interface CatalogBlockDetail extends CatalogBlockSummary { + bestPractices?: string + /** Configuration fields that apply regardless of the selected operation. */ + inputSchema: CatalogSubBlock[] + /** Configuration fields keyed by the operation that reveals them. */ + operationInputSchema: Record + /** Block-level input definitions, keyed by param name. */ + inputDefinitions: Record + operations: Record + tools: CatalogToolDetail[] + triggers: CatalogBlockTrigger[] + outputs: Record +} + +/** How a surface renders a tool's description. Defaults to the tool's own text. */ +export interface BlockDetailProjectionOptions { + describeTool?: (tool: CatalogToolSummary) => string +} + +/** + * Param keys a block supplies itself rather than accepting from an author. + * + * `hideFromCopilot` marks server-only lifecycle configuration — a webhook's + * stored secret, a poller's cursor — that no authoring surface should publish, + * whether the author is an agent or a human writing against the API. + */ +export function hiddenParamKeys(block: BlockConfig): Set { + const hidden = new Set() + for (const subBlock of block.subBlocks ?? []) { + if (!subBlock.hideFromCopilot) continue + if (subBlock.id) hidden.add(subBlock.id) + if (subBlock.canonicalParamId) hidden.add(subBlock.canonicalParamId) + } + return hidden +} + +/** Sub-blocks an authoring surface may configure: action fields, minus the hidden ones. */ +function authorableSubBlocks(block: BlockConfig): SubBlockConfig[] { + return actionSubBlocks(block).filter((subBlock) => !subBlock.hideFromCopilot) +} + +/** Whether a condition gates its field on a specific operation being selected. */ +function operationGate(subBlock: SubBlockConfig): { values: string[] } | undefined { + const condition = normalizeCondition(subBlock.condition) + if (!condition || condition.field !== 'operation' || condition.not) return undefined + if (condition.value === undefined) return undefined + const values = Array.isArray(condition.value) ? condition.value : [condition.value] + return { values: values.map((value) => String(value)) } +} + +/** + * Splits a block's fields into the ones that always apply and the ones each + * operation reveals. + * + * A field gated on `operation` belongs to every operation it names, so a caller + * reading one operation sees exactly the fields that operation needs. Ungated + * fields take their description from the block's own input definitions when one + * is declared, which is where the authored prose lives. + */ +export function splitFieldsByOperation( + subBlocks: SubBlockConfig[], + inputDefinitions: Record = {} +): { + commonFields: CatalogSubBlock[] + operationFields: Record +} { + const commonFields: CatalogSubBlock[] = [] + const operationFields: Record = {} + + for (const subBlock of subBlocks) { + const projected = projectSubBlock(subBlock) + const gate = operationGate(subBlock) + + if (gate) { + for (const operationId of gate.values) { + operationFields[operationId] ??= [] + operationFields[operationId].push(projected) + } + continue + } + + for (const key of [subBlock.id, subBlock.canonicalParamId]) { + if (!key) continue + const definition = inputDefinitions[key] + if (definition && typeof definition.description === 'string') { + projected.description = definition.description + break + } + } + commonFields.push(projected) + } + + return { commonFields, operationFields } +} + +/** Block-level inputs: those not scoped to a single operation and not block-supplied. */ +export function computeBlockLevelInputs( + block: BlockConfig, + hidden = hiddenParamKeys(block) +): Record { + const subBlocksByParamKey = new Map() + for (const subBlock of authorableSubBlocks(block)) { + for (const key of [subBlock.id, subBlock.canonicalParamId]) { + if (!key) continue + const bucket = subBlocksByParamKey.get(key) + if (bucket) bucket.push(subBlock) + else subBlocksByParamKey.set(key, [subBlock]) + } + } + + const blockInputs: Record = {} + for (const [key, definition] of Object.entries(block.inputs ?? {})) { + if (hidden.has(key)) continue + const gated = (subBlocksByParamKey.get(key) ?? []).some((subBlock) => + Boolean(operationGate(subBlock)) + ) + if (!gated) blockInputs[key] = definition + } + return blockInputs +} + +/** Input definitions scoped to one operation, keyed by operation id. */ +export function computeOperationLevelInputs( + block: BlockConfig +): Record> { + const inputs = block.inputs ?? {} + const operationInputs: Record> = {} + + for (const subBlock of authorableSubBlocks(block)) { + const gate = operationGate(subBlock) + if (!gate) continue + const keys = [subBlock.canonicalParamId, subBlock.id].filter( + (key): key is string => typeof key === 'string' + ) + for (const key of keys) { + if (!(key in inputs)) continue + for (const operationId of gate.values) { + operationInputs[operationId] ??= {} + operationInputs[operationId][key] = inputs[key] + } + } + } + + return operationInputs +} + +/** + * The tool a block runs for one operation. + * + * The selector is an authored function invoked with only `{ operation }`, so a + * selector that reads another param can throw. That is a block-authoring + * problem rather than a caller's, so it degrades to "no tool resolved" and is + * logged, exactly as it did before this projection was extracted. + */ +export function resolveToolIdForOperation( + block: BlockConfig, + operationId: string +): string | undefined { + const selector = block.tools?.config?.tool + if (typeof selector !== 'function') return undefined + try { + const toolId = selector({ operation: operationId }) + return typeof toolId === 'string' ? toolId : undefined + } catch (error) { + logger.warn('Failed to resolve tool ID for operation', { + blockType: block.type, + operationId, + error: toError(error).message, + }) + return undefined + } +} + +/** Projects a block's declared outputs, dropping the ones hidden from display. */ +export function projectBlockOutputs( + outputs: BlockConfig['outputs'] | undefined +): Record { + const projected: Record = {} + for (const [key, definition] of Object.entries(outputs ?? {})) { + if (isHiddenFromDisplay(definition)) continue + if (typeof definition === 'string') { + projected[key] = { type: definition } + continue + } + if (!definition || typeof definition !== 'object') continue + const entry: CatalogBlockOutput = { type: String(definition.type ?? 'any') } + if ('description' in definition && typeof definition.description === 'string') { + entry.description = definition.description + } + projected[key] = entry + } + return projected +} + +/** Projects the triggers a block supports, with each trigger's configurable fields. */ +export function projectBlockTriggers(block: BlockConfig): CatalogBlockTrigger[] { + const triggers: CatalogBlockTrigger[] = [] + for (const triggerId of block.triggers?.available ?? []) { + if (!isTriggerValid(triggerId)) { + logger.warn('Block declares an unregistered trigger', { blockType: block.type, triggerId }) + continue + } + const trigger = getTrigger(triggerId) + const configFields: Record = {} + + for (const subBlock of trigger.subBlocks) { + const isTriggerField = subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' + if (!isTriggerField || SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)) continue + + const field: CatalogTriggerConfigField = { + type: subBlock.type, + required: Boolean(subBlock.required), + } + if (subBlock.title) field.title = subBlock.title + if (subBlock.description) field.description = subBlock.description + if (subBlock.placeholder) field.placeholder = subBlock.placeholder + if (subBlock.defaultValue !== undefined) field.default = subBlock.defaultValue + if (Array.isArray(subBlock.options)) { + field.options = subBlock.options.map((option) => ({ + id: option.id, + label: option.label || option.id, + })) + } + const condition = normalizeCondition(subBlock.condition) + if (condition) field.condition = condition + + configFields[subBlock.id] = field + } + + triggers.push({ + id: triggerId, + outputs: projectTriggerOutputs(trigger.outputs), + configFields, + }) + } + return triggers +} + +/** + * A custom (deploy-as-block) block's detail. + * + * A custom block runs a bound workflow through an internal executor, so it has + * no operations and no author-visible tools — only its own input fields and the + * outputs the bound workflow produces. + */ +function projectCustomBlockDetail(block: BlockConfig): CatalogBlockDetail { + const visibleFields = (block.subBlocks ?? []).filter( + (subBlock) => !subBlock.hidden && !subBlock.hideFromCopilot + ) + return { + ...projectBlockSummary(block), + inputSchema: visibleFields.map(projectSubBlock), + operationInputSchema: {}, + inputDefinitions: {}, + operations: {}, + tools: [], + triggers: [], + outputs: projectBlockOutputs(block.outputs), + ...(block.bestPractices !== undefined ? { bestPractices: block.bestPractices } : {}), + } +} + +/** Projects one block config to its full catalog detail. */ +export function projectBlockDetail( + block: BlockConfig, + options: BlockDetailProjectionOptions = {} +): CatalogBlockDetail { + if (isCustomBlockType(block.type)) return projectCustomBlockDetail(block) + + const describeTool = options.describeTool ?? ((tool: CatalogToolSummary) => tool.description) + const hidden = hiddenParamKeys(block) + const inputDefinitions = computeBlockLevelInputs(block, hidden) + const { commonFields, operationFields } = splitFieldsByOperation( + authorableSubBlocks(block), + inputDefinitions + ) + + const tools: CatalogToolDetail[] = [] + for (const toolId of block.tools?.access ?? []) { + const tool = projectToolDetail(toolId) + tools.push( + tool + ? { ...tool, description: describeTool(tool) } + : { + id: toolId, + name: toolId, + description: '', + hostedApiKey: 'none', + params: {}, + outputs: {}, + } + ) + } + + const operationInputs = computeOperationLevelInputs(block) + const operations: Record = {} + for (const operationId of resolveOperationIds(block)) { + const toolId = resolveToolIdForOperation(block, operationId) + const tool = toolId ? projectToolDetail(toolId) : undefined + + const inputs: CatalogBlockOperation['inputs'] = {} + for (const [key, param] of Object.entries(tool?.params ?? {})) { + if (key in inputDefinitions || hidden.has(key)) continue + inputs[key] = param + } + Object.assign(inputs, operationInputs[operationId] ?? {}) + + operations[operationId] = { + inputs, + outputs: tool?.outputs ?? {}, + inputSchema: operationFields[operationId] ?? [], + ...(toolId !== undefined ? { toolId } : {}), + ...(tool ? { toolName: tool.name, description: describeTool(tool) } : {}), + } + } + + return { + ...projectBlockSummary(block), + inputSchema: commonFields, + operationInputSchema: operationFields, + inputDefinitions, + operations, + tools, + triggers: projectBlockTriggers(block), + outputs: projectBlockOutputs(block.outputs), + ...(block.bestPractices !== undefined ? { bestPractices: block.bestPractices } : {}), + } +} diff --git a/apps/sim/lib/catalog/projection/block-summary.ts b/apps/sim/lib/catalog/projection/block-summary.ts new file mode 100644 index 00000000000..e16c781ca64 --- /dev/null +++ b/apps/sim/lib/catalog/projection/block-summary.ts @@ -0,0 +1,124 @@ +import { normalizeCondition } from '@/lib/catalog/projection/subblock' +import { isCustomBlockType } from '@/blocks/custom/build-config' +import { getBlockMeta } from '@/blocks/registry' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' + +/** + * Where a block comes from: the code registry, or a workflow this workspace + * deployed as a block. + */ +export type CatalogBlockSource = 'builtin' | 'custom' + +/** Lifecycle state of a shipped block. */ +export interface CatalogBlockSunset { + status: 'legacy' | 'deprecated' + replacedBy?: string +} + +/** + * List-shaped view of a block: everything needed to decide whether to place it, + * and nothing that requires resolving its tools. + * + * `toolIds` and `operationIds` are identifiers only. Resolving them is a + * `GET /api/v2/tools/{toolId}` or `GET /api/v2/blocks/{blockId}` call, which is + * what keeps a 300-block list under a page's worth of bytes. + */ +export interface CatalogBlockSummary { + id: string + name: string + description: string + longDescription?: string + category: string + integrationType?: string + source: CatalogBlockSource + authMode?: string + triggerAllowed: boolean + /** Whether the block can start a workflow — as a trigger block or in trigger mode. */ + triggerCapable: boolean + triggerIds: string[] + toolIds: string[] + operationIds: string[] + preview: boolean + sunset?: CatalogBlockSunset + docsLink?: string + tags: string[] +} + +/** + * Whether a block can start a workflow. + * + * The three-way predicate is the canonical one: a block in the `triggers` + * category, a block that declares `triggerAllowed`, or a block carrying a + * sub-block that only renders in trigger mode. Single-sourced here because the + * public catalog's `capability=trigger` filter and the Copilot + * `get_trigger_blocks` tool must agree on it. + */ +export function isTriggerCapableBlock(block: BlockConfig): boolean { + if (block.category === 'triggers') return true + if (block.triggerAllowed === true) return true + return block.subBlocks?.some((subBlock) => subBlock.mode === 'trigger') ?? false +} + +/** Sub-blocks that configure the block's action, excluding its trigger-mode fields. */ +export function actionSubBlocks(block: BlockConfig): SubBlockConfig[] { + if (!Array.isArray(block.subBlocks)) return [] + return block.subBlocks.filter( + (subBlock) => subBlock.mode !== 'trigger' && subBlock.mode !== 'trigger-advanced' + ) +} + +/** + * The operations a block exposes, in the order its operation dropdown declares + * them. + * + * Falls back to the operations named by its sub-blocks' `operation` conditions + * for blocks that gate fields on an operation without offering a dropdown. + */ +export function resolveOperationIds(block: BlockConfig): string[] { + const operationField = block.subBlocks?.find((subBlock) => subBlock.id === 'operation') + if (operationField && Array.isArray(operationField.options)) { + const ids = operationField.options.map((option) => option.id).filter(Boolean) + if (ids.length > 0) return ids + } + + const derived: string[] = [] + for (const subBlock of actionSubBlocks(block)) { + const condition = normalizeCondition(subBlock.condition) + if (!condition || condition.field !== 'operation' || condition.not) continue + if (condition.value === undefined) continue + for (const value of Array.isArray(condition.value) ? condition.value : [condition.value]) { + const id = String(value) + if (!derived.includes(id)) derived.push(id) + } + } + return derived +} + +/** Projects one block config down to its catalog summary. */ +export function projectBlockSummary(block: BlockConfig): CatalogBlockSummary { + const summary: CatalogBlockSummary = { + id: block.type, + name: block.name, + description: block.description, + category: block.category, + source: isCustomBlockType(block.type) ? 'custom' : 'builtin', + triggerAllowed: block.triggerAllowed === true, + triggerCapable: isTriggerCapableBlock(block), + triggerIds: block.triggers?.available ?? [], + toolIds: block.tools?.access ?? [], + operationIds: resolveOperationIds(block), + preview: block.preview === true, + tags: [...(getBlockMeta(block.type)?.tags ?? [])], + } + + if (block.longDescription !== undefined) summary.longDescription = block.longDescription + if (block.integrationType !== undefined) summary.integrationType = block.integrationType + if (block.authMode !== undefined) summary.authMode = block.authMode + if (block.docsLink !== undefined) summary.docsLink = block.docsLink + if (block.sunset !== undefined) { + summary.sunset = { status: block.sunset.status } + if (block.sunset.replacedBy !== undefined) summary.sunset.replacedBy = block.sunset.replacedBy + } + + return summary +} diff --git a/apps/sim/lib/catalog/projection/catalog-sweep.test.ts b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts new file mode 100644 index 00000000000..ff037e366fd --- /dev/null +++ b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts @@ -0,0 +1,172 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +/** + * Sweeps every code-defined catalog through its projection and its published + * response schema. + * + * v2 `.parse`s a response on the way out, so a projection that emits a field the + * schema does not declare, or that throws while resolving one, is a 500 on a + * perfectly well-formed request — the highest-severity defect class on the + * surface. These sweeps make that a CI failure at authoring time instead. + * + * Three specific hazards are pinned here rather than defended against at + * runtime, because each is a registry-authoring bug that should fail loudly: + * + * 1. A sub-block `condition` declared as `(values?) => …` is called with no + * arguments. One that dereferences `values` unguarded throws. + * 2. A projected field the response schema does not declare is silently + * stripped by Zod on the way out, so the round-trip comparison below is what + * detects it. + * 3. `getToolIds()` hands out a frozen array, so an in-place `sort()` throws. + */ +vi.unmock('@/blocks/registry') + +import { + v2BlockDetailSchema, + v2BlockSummarySchema, + v2ConnectorTypeSchema, + v2EnrichmentSchema, + v2ToolDetailSchema, + v2ToolSummarySchema, +} from '@/lib/api/contracts/v2/catalog' +import { projectBlockDetail } from '@/lib/catalog/projection/block-detail' +import { projectBlockSummary } from '@/lib/catalog/projection/block-summary' +import { projectConnectorType } from '@/lib/catalog/projection/connector-type' +import { projectEnrichment } from '@/lib/catalog/projection/enrichment' +import { projectToolDetail, projectToolSummaryById } from '@/lib/catalog/projection/tool' +import { getBlockRegistry } from '@/blocks/registry' +import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { ALL_ENRICHMENTS } from '@/enrichments/registry' +import { getToolIds } from '@/tools/tool-ids' + +/** + * Parses a projection against its published schema and asserts nothing was + * stripped. + * + * `.parse` alone is not enough: a response schema is not deeply strict, so an + * undeclared field passes validation and is quietly dropped from the body the + * caller receives. Comparing the parsed output against the JSON round-trip of + * the input catches that at every nesting level. + */ +function expectPublishedIntact( + schema: { parse: (value: unknown) => unknown }, + projection: unknown, + label: string +): void { + /** + * The exact bytes the route would send, read back. Not a deep clone: a v2 + * response is serialized by `NextResponse.json`, so this is what the caller + * actually receives, and comparing the parsed schema output against it is + * what makes a stripped field visible. + */ + const wire = JSON.stringify(projection) + const serialized = JSON.parse(wire) + let parsed: unknown + try { + parsed = schema.parse(serialized) + } catch (error) { + throw new Error(`${label} failed its response schema: ${(error as Error).message}`) + } + expect(parsed, `${label} projects fields its response schema does not declare`).toEqual( + serialized + ) +} + +/** + * Every value a projection produces must survive JSON, so no closure or React + * component leaks out of a registry entry. + * + * Cycles are tracked against the current ancestor chain rather than a global + * seen-set: a projection legitimately shares one field object across several + * operations, and a seen-set reports that ordinary reuse as a cycle. + */ +function expectSerializable(projection: unknown, label: string): void { + const ancestors = new Set() + const walk = (value: unknown, path: string): void => { + if (typeof value === 'function') throw new Error(`${label} leaks a function at ${path}`) + if (!value || typeof value !== 'object') return + if (ancestors.has(value)) throw new Error(`${label} is cyclic at ${path}`) + ancestors.add(value) + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${path}[${index}]`)) + } else { + for (const [key, item] of Object.entries(value)) walk(item, `${path}.${key}`) + } + ancestors.delete(value) + } + walk(projection, label) +} + +describe('block catalog projection sweep', () => { + const blocks = Object.values(getBlockRegistry()) + + it('has a non-empty registry to sweep', () => { + expect(blocks.length).toBeGreaterThan(100) + }) + + it('projects every registered block to a publishable summary', () => { + for (const block of blocks) { + const summary = projectBlockSummary(block) + expectSerializable(summary, `block summary ${block.type}`) + expectPublishedIntact(v2BlockSummarySchema, summary, `block summary ${block.type}`) + } + }) + + it('projects every registered block to a publishable detail', () => { + for (const block of blocks) { + const detail = projectBlockDetail(block) + expectSerializable(detail, `block detail ${block.type}`) + expectPublishedIntact(v2BlockDetailSchema, detail, `block detail ${block.type}`) + } + }) +}) + +describe('tool catalog projection sweep', () => { + const toolIds = getToolIds() + + it('hands out a frozen id list, so a caller must copy before sorting', () => { + expect(Object.isFrozen(toolIds)).toBe(true) + expect(() => (toolIds as string[]).sort()).toThrow(TypeError) + expect(() => [...toolIds].sort()).not.toThrow() + }) + + it('projects every registered tool to a publishable summary and detail', () => { + expect(toolIds.length).toBeGreaterThan(1000) + for (const toolId of toolIds) { + const summary = projectToolSummaryById(toolId) + expect(summary, `tool ${toolId} has no metadata`).toBeDefined() + expectPublishedIntact(v2ToolSummarySchema, summary, `tool summary ${toolId}`) + + const detail = projectToolDetail(toolId) + expect(detail, `tool ${toolId} has no detail`).toBeDefined() + expectSerializable(detail, `tool detail ${toolId}`) + expectPublishedIntact(v2ToolDetailSchema, detail, `tool detail ${toolId}`) + } + }) +}) + +describe('connector-type catalog projection sweep', () => { + it('projects every registered connector type to a publishable entry', () => { + const entries = Object.entries(CONNECTOR_META_REGISTRY) + expect(entries.length).toBeGreaterThan(10) + for (const [connectorType, meta] of entries) { + const projected = projectConnectorType(connectorType, meta) + expectSerializable(projected, `connector ${connectorType}`) + expectPublishedIntact(v2ConnectorTypeSchema, projected, `connector ${connectorType}`) + } + }) +}) + +describe('enrichment catalog projection sweep', () => { + it('projects every registered enrichment to a publishable entry', () => { + expect(ALL_ENRICHMENTS.length).toBeGreaterThan(0) + for (const enrichment of ALL_ENRICHMENTS) { + const projected = projectEnrichment(enrichment) + expectSerializable(projected, `enrichment ${enrichment.id}`) + expectPublishedIntact(v2EnrichmentSchema, projected, `enrichment ${enrichment.id}`) + } + }) +}) diff --git a/apps/sim/lib/catalog/projection/connector-type.ts b/apps/sim/lib/catalog/projection/connector-type.ts new file mode 100644 index 00000000000..5a0c265646a --- /dev/null +++ b/apps/sim/lib/catalog/projection/connector-type.ts @@ -0,0 +1,128 @@ +import type { + ConnectorConfigField, + ConnectorMeta, + ConnectorTagDefinition, +} from '@/connectors/types' + +/** + * Surface-neutral projection of a knowledge-base connector type. + * + * Reads `@/connectors/registry` — the client-safe meta registry — never + * `@/connectors/registry.server`, whose `listDocuments`/`getDocument`/ + * `validateConfig` closures carry `undici` and the server-only input + * validators. The projection also drops `icon`, which is a React component. + */ + +/** How a connector authenticates against its source. */ +export type CatalogConnectorAuth = + | { mode: 'oauth'; provider: string; requiredScopes?: string[] } + | { mode: 'apiKey'; label?: string; placeholder?: string; optional: boolean } + +/** + * One field of a connector's `sourceConfig`. + * + * Two properties decide how a caller sends the value and are not inferable from + * the rest of the field: + * + * - `multi: true` — the persisted `sourceConfig` value is a `string[]`, not a + * `string`. A `selector` field renders a multi-select picker; a `short-input` + * accepts a comma-separated list. Either way the stored value is an array. + * - `canonicalParamId` — links a `selector` field and a manual `short-input` + * field that resolve to the SAME `sourceConfig` key. Send exactly one of the + * pair, keyed by `canonicalParamId` rather than by the field's own `id`. + * `mode` says which half a field is: `basic` is the picker, `advanced` the + * manual entry. + */ +export interface CatalogConnectorConfigField { + id: string + title: string + type: 'short-input' | 'dropdown' | 'selector' + placeholder?: string + required?: boolean + description?: string + options?: { label: string; id: string }[] + /** Names the picker a `selector` field renders. Its options are fetched per workspace. */ + selectorKey?: string + mimeType?: string + dependsOn?: string[] | { all?: string[]; any?: string[] } + mode?: 'basic' | 'advanced' + canonicalParamId?: string + multi?: boolean +} + +/** A tag slot a connector populates on the documents it syncs. */ +export interface CatalogConnectorTagDefinition { + id: string + displayName: string + fieldType: 'text' | 'number' | 'date' | 'boolean' +} + +/** A connector type, as a caller configuring one needs to see it. */ +export interface CatalogConnectorType { + /** Registry key — the exact `connectorType` value to send when creating a connector. */ + connectorType: string + name: string + description: string + version: string + auth: CatalogConnectorAuth + configFields: CatalogConnectorConfigField[] + supportsIncrementalSync: boolean + tagDefinitions: CatalogConnectorTagDefinition[] +} + +function projectAuth(auth: ConnectorMeta['auth']): CatalogConnectorAuth { + if (auth.mode === 'oauth') { + return { + mode: 'oauth', + provider: auth.provider, + ...(auth.requiredScopes !== undefined ? { requiredScopes: [...auth.requiredScopes] } : {}), + } + } + return { + mode: 'apiKey', + optional: auth.optional === true, + ...(auth.label !== undefined ? { label: auth.label } : {}), + ...(auth.placeholder !== undefined ? { placeholder: auth.placeholder } : {}), + } +} + +function projectConfigField(field: ConnectorConfigField): CatalogConnectorConfigField { + const projected: CatalogConnectorConfigField = { + id: field.id, + title: field.title, + type: field.type, + } + if (field.placeholder !== undefined) projected.placeholder = field.placeholder + if (field.required !== undefined) projected.required = field.required + if (field.description !== undefined) projected.description = field.description + if (field.options !== undefined) + projected.options = field.options.map((option) => ({ ...option })) + if (field.selectorKey !== undefined) projected.selectorKey = field.selectorKey + if (field.mimeType !== undefined) projected.mimeType = field.mimeType + if (field.dependsOn !== undefined) projected.dependsOn = field.dependsOn + if (field.mode !== undefined) projected.mode = field.mode + if (field.canonicalParamId !== undefined) projected.canonicalParamId = field.canonicalParamId + if (field.multi !== undefined) projected.multi = field.multi + return projected +} + +function projectTagDefinition(tag: ConnectorTagDefinition): CatalogConnectorTagDefinition { + return { id: tag.id, displayName: tag.displayName, fieldType: tag.fieldType } +} + +/** Projects one connector meta to its catalog entry. */ +export function projectConnectorType( + connectorType: string, + meta: ConnectorMeta +): CatalogConnectorType { + return { + connectorType, + name: meta.name, + description: meta.description, + version: meta.version, + auth: projectAuth(meta.auth), + configFields: meta.configFields.map(projectConfigField), + supportsIncrementalSync: meta.supportsIncrementalSync === true, + tagDefinitions: (meta.tagDefinitions ?? []).map(projectTagDefinition), + } +} diff --git a/apps/sim/lib/catalog/projection/enrichment.ts b/apps/sim/lib/catalog/projection/enrichment.ts new file mode 100644 index 00000000000..ebdb1726eb2 --- /dev/null +++ b/apps/sim/lib/catalog/projection/enrichment.ts @@ -0,0 +1,79 @@ +import type { EnrichmentConfig, EnrichmentOutputField } from '@/enrichments/types' + +/** + * Surface-neutral projection of a table enrichment. + * + * Drops `icon` (a React component) and every provider closure (`buildParams`, + * `mapOutput`). What survives is the part a caller needs: which inputs to map, + * which columns get filled, and which data sources are tried. + */ + +/** One per-row input an enrichment needs, mapped to a table column by the caller. */ +export interface CatalogEnrichmentInput { + id: string + name: string + type: 'string' | 'number' | 'boolean' + required?: boolean + description?: string +} + +/** One value an enrichment produces, which becomes a table column. */ +export interface CatalogEnrichmentOutput { + id: string + name: string + /** + * Table column type the value is stored as. Typed against the column-type + * registry rather than as a bare string, so adding a column type without + * publishing it fails to compile here. + */ + type: EnrichmentOutputField['type'] +} + +/** One data source in an enrichment's fallback cascade. */ +export interface CatalogEnrichmentProvider { + id: string + label: string + /** The built-in tool this provider executes. Resolve it with `GET /api/v2/tools/{toolId}`. */ + toolId: string +} + +/** A code-defined enrichment that fills table cells from external data. */ +export interface CatalogEnrichment { + id: string + name: string + description: string + inputs: CatalogEnrichmentInput[] + outputs: CatalogEnrichmentOutput[] + /** + * Data sources in declared order. This IS the fallback cascade: providers are + * attempted in this order and the first non-empty result fills the cell, so + * the order is part of the behavior rather than presentation. + */ + providers: CatalogEnrichmentProvider[] +} + +/** Projects one enrichment config to its catalog entry. */ +export function projectEnrichment(enrichment: EnrichmentConfig): CatalogEnrichment { + return { + id: enrichment.id, + name: enrichment.name, + description: enrichment.description, + inputs: enrichment.inputs.map((input) => ({ + id: input.id, + name: input.name, + type: input.type, + ...(input.required !== undefined ? { required: input.required } : {}), + ...(input.description !== undefined ? { description: input.description } : {}), + })), + outputs: enrichment.outputs.map((output) => ({ + id: output.id, + name: output.name, + type: output.type, + })), + providers: enrichment.providers.map((provider) => ({ + id: provider.id, + label: provider.label, + toolId: provider.toolId, + })), + } +} diff --git a/apps/sim/lib/catalog/projection/index.ts b/apps/sim/lib/catalog/projection/index.ts new file mode 100644 index 00000000000..eec1f566e93 --- /dev/null +++ b/apps/sim/lib/catalog/projection/index.ts @@ -0,0 +1,15 @@ +/** + * Pure, surface-neutral projections of Sim's code-defined catalogs: blocks, + * tools, connector types, and enrichments. + * + * Nothing here authenticates, reads a database, or touches `next/server`, and + * nothing here imports `@/tools/registry` or `@/connectors/registry.server` — + * both invariants are pinned by `lib/catalog/registry-boundary.test.ts` and by + * the module-graph guard in `scripts/check-tool-registry-boundary.ts`. + */ +export * from '@/lib/catalog/projection/block-detail' +export * from '@/lib/catalog/projection/block-summary' +export * from '@/lib/catalog/projection/connector-type' +export * from '@/lib/catalog/projection/enrichment' +export * from '@/lib/catalog/projection/subblock' +export * from '@/lib/catalog/projection/tool' diff --git a/apps/sim/lib/catalog/projection/subblock.ts b/apps/sim/lib/catalog/projection/subblock.ts new file mode 100644 index 00000000000..4f48de0374b --- /dev/null +++ b/apps/sim/lib/catalog/projection/subblock.ts @@ -0,0 +1,285 @@ +import type { SubBlockConfig } from '@/blocks/types' +import { PROVIDER_DEFINITIONS } from '@/providers/models' + +/** + * Surface-neutral projection of a block's sub-block (its configuration fields) + * down to plain, serializable data. + * + * Pure by construction: no auth, no database, no `next/server`, and no + * `@/tools/registry`. Both the public catalog API and the Copilot + * `get_blocks_metadata` tool read a block's shape through here, so the two can + * never describe the same field differently. + */ + +/** One selectable option on a dropdown, combobox, or multi-select field. */ +export interface CatalogSubBlockOption { + id: string + label?: string + /** Whether the option renders with an icon. The icon component itself is never published. */ + hasIcon?: boolean +} + +/** Scalar a condition compares against. */ +export type CatalogConditionValue = string | number | boolean | Array + +/** + * A resolved visibility or requirement condition on a sub-block: "this field + * applies when `field` holds `value`". + */ +export interface CatalogCondition { + field: string + value: CatalogConditionValue + /** When true, the condition matches every value EXCEPT `value`. */ + not?: boolean + /** A second clause that must hold as well. */ + and?: { + field: string + value: CatalogConditionValue | undefined + not?: boolean + } +} + +/** Declarative dependency hint: which sibling fields must hold a value. */ +export type CatalogDependsOn = string[] | { all?: string[]; any?: string[] } + +/** A block configuration field, projected to serializable data. */ +export interface CatalogSubBlock { + id: string + type: string + title?: string + /** Whether the field must be supplied. A conditionally-required field reports `true`. */ + required?: boolean + /** The condition under which the field is required, when requirement is conditional. */ + requiredWhen?: CatalogCondition + description?: string + placeholder?: string + mode?: string + hidden?: boolean + /** The condition under which the field applies at all. */ + condition?: CatalogCondition + options?: CatalogSubBlockOption[] + min?: number + max?: number + step?: number + integer?: boolean + rows?: number + password?: boolean + multiSelect?: boolean + language?: string + generationType?: string + serviceId?: string + requiredScopes?: string[] + mimeType?: string + acceptedTypes?: string + multiple?: boolean + maxSize?: number + connectionDroppable?: boolean + columns?: string[] + dependsOn?: CatalogDependsOn + canonicalParamId?: string + defaultValue?: string | number | boolean | Record | Array + /** + * Whether the field derives its value from the block's other values rather + * than holding one of its own. The deriving function is never published. + */ + hasComputedDefault?: boolean +} + +/** + * Resolves a condition to plain data, evaluating the function form. + * + * The function form is declared `(values?: Record) => …`, so + * calling it with no arguments is exactly what its signature permits. It is + * deliberately NOT wrapped in a `try`/`catch`: a condition that dereferences + * `values` without guarding it is a block-authoring bug, and swallowing it here + * would drop the field's condition silently on every surface. `block-detail`'s + * registry sweep asserts no registered block has one. + */ +export function normalizeCondition( + condition: SubBlockConfig['condition'] +): CatalogCondition | undefined { + if (!condition) return undefined + return typeof condition === 'function' ? condition() : condition +} + +/** + * Whether a field is required, and under what condition. + * + * `required` shares the condition shape with `condition`, so a conditionally + * required field resolves to `required: true` plus the clause that decides it — + * never the raw object or function, which is not serializable. + */ +function normalizeRequired(required: SubBlockConfig['required']): { + required?: boolean + requiredWhen?: CatalogCondition +} { + if (required === undefined) return {} + if (typeof required === 'boolean') return { required } + const requiredWhen = typeof required === 'function' ? required() : required + return { required: true, requiredWhen } +} + +/** + * Models offered as static dropdown options when no provider store is available. + * + * Providers whose model list is fetched at runtime are skipped — a catalog must + * not publish an option set it cannot know — and retired models are excluded so + * a caller never receives one whose API calls fail. + */ +function staticModelOptions(): CatalogSubBlockOption[] { + const models: CatalogSubBlockOption[] = [] + for (const provider of Object.values(PROVIDER_DEFINITIONS)) { + if (DYNAMIC_MODEL_PROVIDERS.has(provider.id)) continue + for (const model of provider.models ?? []) { + if (model.sunset?.status === 'deprecated') continue + models.push({ id: model.id, label: model.id }) + } + } + return models +} + +/** Providers whose model list is fetched at runtime rather than declared in code. */ +const DYNAMIC_MODEL_PROVIDERS = new Set([ + 'ollama', + 'ollama-cloud', + 'vllm', + 'openrouter', + 'fireworks', + 'together', + 'baseten', +]) + +/** Shape of the providers store this projection substitutes while resolving options. */ +interface ProvidersStateLike { + providers: Record +} + +/** + * Calls a dynamic options function with static provider data substituted for the + * client store it would otherwise read. + * + * The model dropdowns read `useProvidersStore`, which has no state outside the + * browser. Substituting the code-defined model list is what lets a server-side + * projection publish the same options a user sees, instead of an empty list. + */ +function callOptionsWithFallback( + optionsFn: () => CatalogSubBlockOption[] +): CatalogSubBlockOption[] | undefined { + const staticModels = staticModelOptions() + const substituteState: ProvidersStateLike = { + providers: { + base: { models: staticModels.map((model) => model.id) }, + ...Object.fromEntries([...DYNAMIC_MODEL_PROVIDERS].map((id) => [id, { models: [] }])), + litellm: { models: [] }, + }, + } + + let store: { useProvidersStore?: { getState: () => unknown } } | undefined + let originalGetState: (() => unknown) | undefined + + try { + store = require('@/stores/providers') + if (store?.useProvidersStore?.getState) { + originalGetState = store.useProvidersStore.getState + store.useProvidersStore.getState = () => substituteState + } + } catch { + /* The store module is unavailable in this environment; the fallback stands alone. */ + } + + try { + return optionsFn() + } finally { + if (store?.useProvidersStore && originalGetState) { + store.useProvidersStore.getState = originalGetState + } + } +} + +/** + * Resolves a field's selectable options, or `undefined` when it has none the + * catalog can know. + * + * A `selectorKey` field fetches its options from a live API per workspace, so it + * has no static option set to publish. An options *function* is called, and a + * failure yields no options rather than propagating: unlike `condition`, these + * functions legitimately reach for client state that may not exist. + */ +export function resolveSubBlockOptions( + subBlock: SubBlockConfig +): CatalogSubBlockOption[] | undefined { + let rawOptions: SubBlockConfig['options'] + try { + rawOptions = + typeof subBlock.options === 'function' + ? (callOptionsWithFallback(subBlock.options as () => CatalogSubBlockOption[]) as + | SubBlockConfig['options'] + | undefined) + : subBlock.options + } catch { + return undefined + } + + if (!Array.isArray(rawOptions) || rawOptions.length === 0) return undefined + + const normalized: CatalogSubBlockOption[] = [] + for (const option of rawOptions) { + if (!option || option.id === undefined || option.id === null) continue + const projected: CatalogSubBlockOption = { id: String(option.id) } + if (typeof option.label === 'string') projected.label = option.label + if (option.icon) projected.hasIcon = true + normalized.push(projected) + } + + return normalized.length > 0 ? normalized : undefined +} + +/** Assigns `key` only when `value` is neither `undefined` nor `null`. */ +function assignDefined(target: T, key: K, value: T[K]): void { + if (value !== undefined && value !== null) target[key] = value +} + +/** Projects one sub-block config down to serializable catalog data. */ +export function projectSubBlock(subBlock: SubBlockConfig): CatalogSubBlock { + const projected: CatalogSubBlock = { id: subBlock.id, type: subBlock.type } + + assignDefined(projected, 'title', subBlock.title) + assignDefined(projected, 'description', subBlock.description) + assignDefined(projected, 'placeholder', subBlock.placeholder) + assignDefined(projected, 'mode', subBlock.mode) + assignDefined(projected, 'hidden', subBlock.hidden) + assignDefined(projected, 'canonicalParamId', subBlock.canonicalParamId) + assignDefined(projected, 'defaultValue', subBlock.defaultValue) + assignDefined(projected, 'min', subBlock.min) + assignDefined(projected, 'max', subBlock.max) + assignDefined(projected, 'step', subBlock.step) + assignDefined(projected, 'integer', subBlock.integer) + assignDefined(projected, 'rows', subBlock.rows) + assignDefined(projected, 'password', subBlock.password) + assignDefined(projected, 'multiSelect', subBlock.multiSelect) + assignDefined(projected, 'language', subBlock.language) + assignDefined(projected, 'generationType', subBlock.generationType) + assignDefined(projected, 'serviceId', subBlock.serviceId) + assignDefined(projected, 'requiredScopes', subBlock.requiredScopes) + assignDefined(projected, 'mimeType', subBlock.mimeType) + assignDefined(projected, 'acceptedTypes', subBlock.acceptedTypes) + assignDefined(projected, 'multiple', subBlock.multiple) + assignDefined(projected, 'maxSize', subBlock.maxSize) + assignDefined(projected, 'connectionDroppable', subBlock.connectionDroppable) + assignDefined(projected, 'columns', subBlock.columns) + assignDefined(projected, 'dependsOn', subBlock.dependsOn) + + const { required, requiredWhen } = normalizeRequired(subBlock.required) + assignDefined(projected, 'required', required) + assignDefined(projected, 'requiredWhen', requiredWhen) + + const condition = normalizeCondition(subBlock.condition) + if (condition !== undefined) projected.condition = condition + + if (typeof subBlock.value === 'function') projected.hasComputedDefault = true + + const options = resolveSubBlockOptions(subBlock) + if (options) projected.options = options + + return projected +} diff --git a/apps/sim/lib/catalog/projection/tool.ts b/apps/sim/lib/catalog/projection/tool.ts new file mode 100644 index 00000000000..6117285e334 --- /dev/null +++ b/apps/sim/lib/catalog/projection/tool.ts @@ -0,0 +1,148 @@ +import { isHiddenFromDisplay } from '@/blocks/types' +import type { HostedApiKeySupport } from '@/tools/hosted-api-key' +import { getToolMetadata, type ToolMetadata } from '@/tools/metadata' +import { getToolOutputsMetadata } from '@/tools/metadata-outputs' +import { resolveToolId } from '@/tools/tool-ids' +import type { ToolConfig } from '@/tools/types' + +/** + * Surface-neutral projection of a built-in tool. + * + * Reads `@/tools/metadata`, `@/tools/metadata-outputs`, and `@/tools/tool-ids` — + * never `@/tools/registry`. Everything published here is plain data the + * generator already emits; reaching the executable registry for it would add + * ~4,700 modules to every graph that touches this module. + */ + +/** One declared parameter of a tool. */ +export interface CatalogToolParam { + type: string + required?: boolean + visibility?: string + description?: string + default?: unknown + /** JSON-Schema-shaped constraints for structured params. Provider-defined and arbitrarily nested. */ + items?: unknown +} + +/** One declared output field of a tool. */ +export interface CatalogToolOutput { + type: string + description?: string + optional?: boolean + nullable?: boolean + properties?: Record + items?: { type: string; description?: string; properties?: Record } + fileConfig?: { mimeType?: string; extension?: string } +} + +/** OAuth requirement declared by a tool. */ +export interface CatalogToolOAuth { + required: boolean + provider: string + requiredScopes?: string[] +} + +/** List-shaped view of a tool: identity, auth, and how its API key is supplied. */ +export interface CatalogToolSummary { + id: string + name: string + description: string + version?: string + hostedApiKey: HostedApiKeySupport + oauth?: CatalogToolOAuth +} + +/** A tool plus the parameters it accepts and the outputs it declares. */ +export interface CatalogToolDetail extends CatalogToolSummary { + params: Record + outputs: Record +} + +function projectOAuth(oauth: ToolMetadata['oauth']): CatalogToolOAuth | undefined { + if (!oauth) return undefined + const projected: CatalogToolOAuth = { required: oauth.required, provider: oauth.provider } + if (oauth.requiredScopes !== undefined) projected.requiredScopes = [...oauth.requiredScopes] + return projected +} + +/** + * Projects tool metadata to its catalog summary under a resolved registry id. + * + * The id is passed in rather than read off the metadata because the registry + * key is what `tools.access` and every caller reference, and the metadata's own + * `id` field is authored separately from it. + * + * `name` and `description` fall back to the id: both are optional on the + * generated artifact, and a catalog entry with an empty name is unusable. + * `hostedApiKey` falls back to `none`, which is what an artifact generated + * before the field existed means. + */ +export function projectToolSummary(toolId: string, metadata: ToolMetadata): CatalogToolSummary { + const summary: CatalogToolSummary = { + id: toolId, + name: metadata.name ?? toolId, + description: metadata.description ?? '', + hostedApiKey: metadata.hostedApiKey ?? 'none', + } + if (metadata.version !== undefined) summary.version = metadata.version + const oauth = projectOAuth(metadata.oauth) + if (oauth) summary.oauth = oauth + return summary +} + +/** Projects one declared tool parameter. */ +export function projectToolParams( + params: ToolConfig['params'] | undefined +): Record { + const projected: Record = {} + for (const [id, param] of Object.entries(params ?? {})) { + if (!param) continue + const entry: CatalogToolParam = { type: param.type } + if (param.required !== undefined) entry.required = param.required + if (param.visibility !== undefined) entry.visibility = param.visibility + if (param.description !== undefined) entry.description = param.description + if (param.default !== undefined) entry.default = param.default + if (param.items !== undefined) entry.items = param.items + projected[id] = entry + } + return projected +} + +/** Projects a tool's declared outputs, dropping any marked hidden from display. */ +export function projectToolOutputs( + outputs: NonNullable | undefined +): Record { + const projected: Record = {} + for (const [id, output] of Object.entries(outputs ?? {})) { + if (!output || isHiddenFromDisplay(output)) continue + projected[id] = output as CatalogToolOutput + } + return projected +} + +/** + * Projects a tool to its full catalog entry, or `undefined` when no such tool + * exists. + * + * The lookup resolves an unversioned name onto the newest version, exactly as + * execution does, so `gmail_send` finds `gmail_send_v2`. The returned `id` is + * the resolved one, so a caller can always see which version answered. + */ +export function projectToolDetail(toolId: string): CatalogToolDetail | undefined { + const resolved = resolveToolId(toolId) + const metadata = getToolMetadata(resolved) + if (!metadata) return undefined + return { + ...projectToolSummary(resolved, metadata), + params: projectToolParams(metadata.params), + outputs: projectToolOutputs(getToolOutputsMetadata(resolved)), + } +} + +/** Projects a tool to its catalog summary, or `undefined` when no such tool exists. */ +export function projectToolSummaryById(toolId: string): CatalogToolSummary | undefined { + const resolved = resolveToolId(toolId) + const metadata = getToolMetadata(resolved) + return metadata ? projectToolSummary(resolved, metadata) : undefined +} diff --git a/apps/sim/lib/catalog/registry-boundary.test.ts b/apps/sim/lib/catalog/registry-boundary.test.ts new file mode 100644 index 00000000000..4e0190c1160 --- /dev/null +++ b/apps/sim/lib/catalog/registry-boundary.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Pins the import boundary the catalog exists to protect. + * + * `@/tools/registry` is a barrel over 4,300+ tools whose configs hold closures; + * reaching it costs ~4,700 modules. Everything the catalog reads — a tool's + * params, outputs, name, or existence — is exactly what the generated metadata + * artifacts carry, so a single `getTool` import here would be pure cost. The + * same applies to `@/connectors/registry.server`, whose fetch closures pull + * `undici` and the server-only input validators in behind them. + * + * `scripts/check-tool-registry-boundary.ts` walks these same entries and would + * also catch a registry edge. This test is the cheaper, faster half: it runs in + * the normal suite, names the offending file directly, and additionally holds + * `projection/` to the stricter rule that it stay free of HTTP and database + * imports so any surface — including a client component — can import it. + */ + +const APP_ROOT = join(import.meta.dirname, '..', '..') + +const CATALOG_ROOTS = [ + 'lib/catalog', + 'app/api/v2/blocks', + 'app/api/v2/tools', + 'app/api/v2/connector-types', + 'app/api/v2/enrichments', +] as const + +/** Modules no catalog file may import, with what each would drag in. */ +const FORBIDDEN_EVERYWHERE: Record = { + '@/tools/registry': 'the executable tool registry (~4,700 modules of tool closures)', + '@/connectors/registry.server': + 'the server connector registry (fetch closures, undici, server-only validators)', + '@/tools/utils': 'getTool, which resolves through the executable tool registry', +} + +/** Additional modules the pure projection layer may not import. */ +const FORBIDDEN_IN_PROJECTION: Record = { + 'next/server': 'the HTTP surface; a projection must stay surface-neutral', + '@sim/db': 'the database; a projection reads code-defined registries only', + '@/enrichments/run': 'the enrichment cascade runner, which executes tools', +} + +function collectSourceFiles(root: string): string[] { + const found: string[] = [] + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) walk(full) + else if (/\.tsx?$/.test(entry.name)) found.push(full) + } + } + walk(join(APP_ROOT, root)) + return found +} + +function importedModules(source: string): string[] { + const specifiers: string[] = [] + const patterns = [ + /(?:^|\n)\s*import\s+(?!type\b)(?:[\s\S]*?from\s*)??['"]([^'"]+)['"]/g, + /(?:^|\n)\s*export\s+(?!type\b)(?:\*(?:\s+as\s+[\w$]+)?|\{[\s\S]*?\})\s*from\s*['"]([^'"]+)['"]/g, + /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g, + ] + for (const pattern of patterns) { + pattern.lastIndex = 0 + let match = pattern.exec(source) + while (match !== null) { + specifiers.push(match[1]) + match = pattern.exec(source) + } + } + return specifiers +} + +describe('catalog registry boundary', () => { + const files = CATALOG_ROOTS.flatMap(collectSourceFiles) + + it('finds catalog sources to check', () => { + expect(files.length).toBeGreaterThan(10) + }) + + it('never imports the executable tool or connector registries', () => { + for (const file of files) { + if (file.endsWith('registry-boundary.test.ts')) continue + const imports = importedModules(readFileSync(file, 'utf8')) + for (const [specifier, reason] of Object.entries(FORBIDDEN_EVERYWHERE)) { + expect( + imports.includes(specifier), + `${relative(APP_ROOT, file)} imports ${specifier} — ${reason}` + ).toBe(false) + } + } + }) + + it('keeps the projection layer free of HTTP and database imports', () => { + for (const file of collectSourceFiles('lib/catalog/projection')) { + if (file.endsWith('.test.ts')) continue + const imports = importedModules(readFileSync(file, 'utf8')) + for (const [specifier, reason] of Object.entries(FORBIDDEN_IN_PROJECTION)) { + expect( + imports.includes(specifier), + `${relative(APP_ROOT, file)} imports ${specifier} — ${reason}` + ).toBe(false) + } + } + }) +}) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/copilot/chat/payload.ts index ef1ea48d159..648ed5a2df7 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -29,6 +29,7 @@ import { import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { buildArchiveExtractGuidance, isArchiveFileName } from '@/lib/uploads/utils/file-utils' +import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key' const logger = createLogger('CopilotChatPayload') const INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS = 5_000 @@ -247,6 +248,7 @@ async function buildIntegrationToolSchemasUncached( operation, description: getCopilotToolDescription(toolConfig, { isHosted, + hostedApiKey: deriveHostedApiKeySupport(toolConfig.hosting), fallbackName: toolId, appendEmailTagline: shouldAppendEmailTagline, }), diff --git a/apps/sim/lib/copilot/tools/descriptions.test.ts b/apps/sim/lib/copilot/tools/descriptions.test.ts index ef5e3a3871b..6ac4dc43c9c 100644 --- a/apps/sim/lib/copilot/tools/descriptions.test.ts +++ b/apps/sim/lib/copilot/tools/descriptions.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key' import { getCopilotToolDescription } from './descriptions' describe('getCopilotToolDescription', () => { @@ -9,9 +10,11 @@ describe('getCopilotToolDescription', () => { id: 'brandfetch_search', name: 'Brandfetch Search', description: 'Search for brands by company name', - hosting: { apiKeyParam: 'apiKey' } as never, }, - { isHosted: false } + { + isHosted: false, + hostedApiKey: deriveHostedApiKeySupport({ apiKeyParam: 'apiKey' } as never), + } ) ).toBe('Search for brands by company name') }) @@ -23,9 +26,11 @@ describe('getCopilotToolDescription', () => { id: 'brandfetch_search', name: 'Brandfetch Search', description: 'Search for brands by company name', - hosting: { apiKeyParam: 'apiKey' } as never, }, - { isHosted: true } + { + isHosted: true, + hostedApiKey: deriveHostedApiKeySupport({ apiKeyParam: 'apiKey' } as never), + } ) ).toBe('Search for brands by company name API key is hosted by Sim.') }) @@ -37,9 +42,14 @@ describe('getCopilotToolDescription', () => { id: 'image_generate', name: 'Image Generate', description: 'Generate an image', - hosting: { apiKeyParam: 'apiKey', enabled: () => true } as never, }, - { isHosted: true } + { + isHosted: true, + hostedApiKey: deriveHostedApiKeySupport({ + apiKeyParam: 'apiKey', + enabled: () => true, + } as never), + } ) ).toBe( 'Generate an image API key is hosted by Sim when hosted-key support applies to the selected configuration.' @@ -53,9 +63,12 @@ describe('getCopilotToolDescription', () => { id: 'brandfetch_search', name: '', description: '', - hosting: { apiKeyParam: 'apiKey' } as never, }, - { isHosted: true, fallbackName: 'brandfetch_search' } + { + isHosted: true, + hostedApiKey: deriveHostedApiKeySupport({ apiKeyParam: 'apiKey' } as never), + fallbackName: 'brandfetch_search', + } ) ).toBe('brandfetch_search API key is hosted by Sim.') }) diff --git a/apps/sim/lib/copilot/tools/descriptions.ts b/apps/sim/lib/copilot/tools/descriptions.ts index 0defd9013c3..5b89d1e4871 100644 --- a/apps/sim/lib/copilot/tools/descriptions.ts +++ b/apps/sim/lib/copilot/tools/descriptions.ts @@ -1,3 +1,4 @@ +import type { HostedApiKeySupport } from '@/tools/hosted-api-key' import type { ToolConfig } from '@/tools/types' const HOSTED_API_KEY_NOTE = 'API key is hosted by Sim.' @@ -7,10 +8,18 @@ const EMAIL_TAGLINE_NOTE = 'Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.' const EMAIL_TAGLINE_TOOL_IDS = new Set(['gmail_send', 'gmail_send_v2', 'outlook_send']) +/** + * `hostedApiKey` is an option rather than a field read off `tool` because the + * two sources that can answer it differ: an executable `ToolConfig` carries the + * `hosting` closure (project it with `deriveHostedApiKeySupport`), while the + * generated tool metadata carries the derived answer directly. Taking it as an + * argument keeps one branch here and lets either source supply it. + */ export function getCopilotToolDescription( - tool: Pick, + tool: Pick, options?: { isHosted?: boolean + hostedApiKey?: HostedApiKeySupport fallbackName?: string appendEmailTagline?: boolean } @@ -18,13 +27,16 @@ export function getCopilotToolDescription( const baseDescription = tool.description || tool.name || options?.fallbackName || '' const notes: string[] = [] + const hostedApiKey = options?.hostedApiKey ?? 'none' if ( options?.isHosted && - tool.hosting && + hostedApiKey !== 'none' && !baseDescription.includes(HOSTED_API_KEY_NOTE) && !baseDescription.includes(CONDITIONAL_HOSTED_API_KEY_NOTE) ) { - notes.push(tool.hosting.enabled ? CONDITIONAL_HOSTED_API_KEY_NOTE : HOSTED_API_KEY_NOTE) + notes.push( + hostedApiKey === 'conditional' ? CONDITIONAL_HOSTED_API_KEY_NOTE : HOSTED_API_KEY_NOTE + ) } if ( diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts new file mode 100644 index 00000000000..4c32b93369a --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Pins that the agent's block metadata still resolves tool params, outputs, and + * the hosted-key note after the projection moved to the shared catalog layer and + * off `@/tools/registry`. + * + * The sibling suite exercises this tool's gating against a mocked registry; this + * one runs it against the real block registry and the real generated tool + * metadata, because the thing worth proving is exactly that the metadata + * artifacts can answer everything the executable registry used to. + */ +vi.unmock('@/blocks/registry') + +const mocks = vi.hoisted(() => ({ + getUserPermissionConfig: vi.fn(), + isDeploymentAvailable: vi.fn(() => true), +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +vi.mock('@/lib/integrations/availability.server', () => ({ + isIntegrationDeploymentAvailableForVisibility: mocks.isDeploymentAvailable, +})) + +import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' + +interface AgentBlockMetadata { + blockType: string + name: string + description: string + operations?: Record< + string, + { name: string; description?: string; inputs: { required: unknown[]; optional: unknown[] } } + > + inputs?: { required: unknown[]; optional: unknown[] } +} + +describe('get_blocks_metadata against the real registries', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null }) + mocks.isDeploymentAvailable.mockReturnValue(true) + }) + + it('resolves an integration block’s operations and their tool-derived inputs', async () => { + const result = await getBlocksMetadataServerTool.execute( + { blockIds: ['slack'] }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + const slack = result.metadata.slack as AgentBlockMetadata + expect(slack.blockType).toBe('slack') + expect(slack.name).toBe('Slack') + + const operations = slack.operations ?? {} + expect(Object.keys(operations).length).toBeGreaterThan(0) + for (const [operationId, operation] of Object.entries(operations)) { + expect(operation.name, operationId).toBeTruthy() + expect(operation.inputs, operationId).toBeDefined() + } + + /** + * The point of the rewrite: these inputs come from the generated tool + * metadata. An empty set everywhere means the tool params stopped being + * resolved, which is what reaching for the executable registry used to buy. + */ + const parameterCount = Object.values(operations).reduce( + (total, operation) => + total + operation.inputs.required.length + operation.inputs.optional.length, + 0 + ) + expect(parameterCount).toBeGreaterThan(0) + }) + + it('still describes the control-flow blocks it defines itself', async () => { + const result = await getBlocksMetadataServerTool.execute( + { blockIds: ['loop'] }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + const loop = result.metadata.loop as AgentBlockMetadata + expect(loop.blockType).toBe('loop') + expect(loop.inputs?.required.length).toBeGreaterThan(0) + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts index f286dc7a0c5..26cf17e8b70 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts @@ -17,10 +17,8 @@ vi.mock('@/lib/integrations/availability.server', () => ({ isIntegrationDeploymentAvailableForVisibility: mockIsIntegrationDeploymentAvailable, })) -import { - computeBlockLevelInputs, - getBlocksMetadataServerTool, -} from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' +import { computeBlockLevelInputs } from '@/lib/catalog/projection/block-detail' +import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' import { MothershipBlock } from '@/blocks/blocks/mothership' describe('get blocks metadata', () => { diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index 9c46dc976cc..0ed958c6bb0 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -3,6 +3,14 @@ import { join } from 'path' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { z } from 'zod' +import { + type CatalogBlockDetail, + type CatalogInputDefinition, + projectBlockDetail, + splitFieldsByOperation, +} from '@/lib/catalog/projection/block-detail' +import type { CatalogSubBlock } from '@/lib/catalog/projection/subblock' +import type { CatalogToolSummary } from '@/lib/catalog/projection/tool' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/env-flags' @@ -10,73 +18,33 @@ import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integration import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { isCustomBlockType } from '@/blocks/custom/build-config' import { getBlock } from '@/blocks/registry' -import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types' +import { AuthMode, type BlockConfig, type SubBlockConfig } from '@/blocks/types' import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' -import { PROVIDER_DEFINITIONS } from '@/providers/models' -import { tools as toolsRegistry } from '@/tools/registry' -import { getTrigger, isTriggerValid } from '@/triggers' -import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' -interface CopilotSubblockMetadata { - id: string - type: string - title?: string - required?: boolean - description?: string - placeholder?: string - layout?: string - mode?: string - hidden?: boolean - condition?: any - // Dropdown/combobox options - options?: { id: string; label?: string; hasIcon?: boolean }[] - // Numeric constraints - min?: number - max?: number - step?: number - integer?: boolean - // Text input properties - rows?: number - password?: boolean - multiSelect?: boolean - // Code/generation properties - language?: string - generationType?: string - // OAuth/credential properties - serviceId?: string - requiredScopes?: string[] - // File properties - mimeType?: string - acceptedTypes?: string - multiple?: boolean - maxSize?: number - // Other properties - connectionDroppable?: boolean - columns?: string[] - wandConfig?: any - availableTriggers?: string[] - triggerProvider?: string - dependsOn?: string[] - canonicalParamId?: string - defaultValue?: any - value?: string // 'function' if it's a function, undefined otherwise -} +/** + * The block shape this tool reports, projected by the shared catalog projection + * (`@/lib/catalog/projection`) and then reshaped for the agent below. + * + * The projection reads tool params and outputs from `@/tools/metadata` and + * `@/tools/metadata-outputs` rather than the executable registry, which is what + * keeps this module's graph off the ~4,700 modules `@/tools/registry` costs. + */ +type CopilotSubblockMetadata = CatalogSubBlock interface CopilotToolMetadata { id: string name: string description?: string - inputs?: any - outputs?: any + inputs?: Record + outputs?: Record } interface CopilotTriggerMetadata { id: string - outputs?: any - configFields?: any + outputs?: Record + configFields?: Record } interface CopilotBlockMetadata { @@ -109,6 +77,45 @@ interface CopilotBlockMetadata { const GetBlocksMetadataInputSchema = z.object({ blockIds: z.array(z.string()).min(1) }) const GetBlocksMetadataResultSchema = z.object({ metadata: z.record(z.string(), z.any()) }) +/** + * Prompt-shaped tool description: the raw text plus the hosted-key note the + * agent needs. The public catalog publishes the raw description and a structured + * `hostedApiKey` instead, which is why this stays a Copilot concern rather than + * moving into the shared projection. + */ +function describeToolForAgent(tool: CatalogToolSummary): string { + return getCopilotToolDescription(tool, { + isHosted, + hostedApiKey: tool.hostedApiKey, + fallbackName: tool.id, + }) +} + +/** Reshapes the shared block projection into the agent-facing metadata above. */ +function toCopilotBlockMetadata(detail: CatalogBlockDetail): CopilotBlockMetadata { + return removeNullish({ + id: detail.id, + name: detail.name, + description: detail.longDescription || detail.description || '', + bestPractices: detail.bestPractices, + inputSchema: detail.inputSchema, + inputDefinitions: detail.inputDefinitions, + triggerAllowed: detail.triggerAllowed, + authType: resolveAuthType(detail.authMode as AuthMode | undefined), + tools: detail.tools.map((tool) => ({ + id: tool.id, + name: tool.name, + description: tool.description, + inputs: tool.params, + outputs: tool.outputs, + })), + triggers: detail.triggers, + operationInputSchema: detail.operationInputSchema, + operations: detail.operations, + outputs: detail.outputs, + }) as CopilotBlockMetadata +} + export const getBlocksMetadataServerTool: BaseServerTool< z.infer, z.infer @@ -150,25 +157,25 @@ export const getBlocksMetadataServerTool: BaseServerTool< continue } - let metadata: any + let metadata: CopilotBlockMetadata if (specialBlock) { - const { commonParameters, operationParameters } = splitParametersByOperation( - specialBlock.subBlocks || [], - specialBlock.inputs || {} + const inputDefinitions: Record = specialBlock.inputs || {} + const { commonFields, operationFields } = splitFieldsByOperation( + (specialBlock.subBlocks || []) as SubBlockConfig[], + inputDefinitions ) metadata = { id: specialBlock.id, name: specialBlock.name, description: specialBlock.description || '', - inputSchema: commonParameters, - inputDefinitions: specialBlock.inputs || {}, + inputSchema: commonFields, + inputDefinitions, tools: [], triggers: [], - operationInputSchema: operationParameters, + operationInputSchema: operationFields, outputs: specialBlock.outputs, } - ;(metadata as any).subBlocks = undefined } else { const blockConfig: BlockConfig | undefined = getBlock(blockId) if (!blockConfig) { @@ -190,170 +197,9 @@ export const getBlocksMetadataServerTool: BaseServerTool< continue } - if (isCustomBlockType(blockId)) { - // Custom (deploy-as-block) blocks run a bound workflow via an internal - // `workflow_executor`; the agent never configures a workflowId/inputMapping. - // Present it as self-contained: its visible input fields + curated outputs, - // no tools/operations. - const visibleSubBlocks = (blockConfig.subBlocks || []).filter( - (sb) => !sb.hidden && !sb.hideFromCopilot - ) - const outputs = blockConfig.outputs - ? Object.fromEntries( - Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) - ) - : undefined - metadata = { - id: blockId, - name: blockConfig.name || blockId, - description: blockConfig.longDescription || blockConfig.description || '', - bestPractices: blockConfig.bestPractices, - inputSchema: visibleSubBlocks.map(processSubBlock), - inputDefinitions: {}, - tools: [], - triggers: [], - operationInputSchema: {}, - outputs, - } - result[blockId] = removeNullish(metadata) as CopilotBlockMetadata - continue - } - - const tools: CopilotToolMetadata[] = Array.isArray(blockConfig.tools?.access) - ? blockConfig.tools!.access.map((toolId) => { - const tool = toolsRegistry[toolId] - if (!tool) return { id: toolId, name: toolId } - return { - id: toolId, - name: tool.name || toolId, - description: getCopilotToolDescription(tool, { - isHosted, - fallbackName: toolId, - }), - inputs: tool.params || {}, - outputs: tool.outputs || {}, - } - }) - : [] - - const triggers: CopilotTriggerMetadata[] = [] - const availableTriggerIds = blockConfig.triggers?.available || [] - for (const tid of availableTriggerIds) { - if (!isTriggerValid(tid)) { - logger.debug('Invalid trigger ID found in block config', { blockId, triggerId: tid }) - continue - } - - const trig = getTrigger(tid) - - const configFields: Record = {} - for (const subBlock of trig.subBlocks) { - if ( - (subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced') && - !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id) - ) { - const fieldDef: any = { - type: subBlock.type, - required: subBlock.required || false, - } - - if (subBlock.title) fieldDef.title = subBlock.title - if (subBlock.description) fieldDef.description = subBlock.description - if (subBlock.placeholder) fieldDef.placeholder = subBlock.placeholder - if (subBlock.defaultValue !== undefined) fieldDef.default = subBlock.defaultValue - - if (subBlock.options && Array.isArray(subBlock.options)) { - fieldDef.options = subBlock.options.map((opt: any) => ({ - id: opt.id, - label: opt.label || opt.id, - })) - } - - if (subBlock.condition) { - const cond = - typeof subBlock.condition === 'function' - ? subBlock.condition() - : subBlock.condition - if (cond) { - fieldDef.condition = cond - } - } - - configFields[subBlock.id] = fieldDef - } - } - - triggers.push({ - id: tid, - outputs: trig.outputs || {}, - configFields, - }) - } - - const hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) - const blockInputs = computeBlockLevelInputs(blockConfig, hiddenParamKeys) - const { commonParameters, operationParameters } = splitParametersByOperation( - Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter( - (sb) => - !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' - ) - : [], - blockInputs + metadata = toCopilotBlockMetadata( + projectBlockDetail(blockConfig, { describeTool: describeToolForAgent }) ) - - const operationInputs = computeOperationLevelInputs(blockConfig) - const operationIds = resolveOperationIds(blockConfig, operationParameters) - const operations: Record = {} - for (const opId of operationIds) { - const resolvedToolId = resolveToolIdForOperation(blockConfig, opId) - const toolCfg = resolvedToolId ? toolsRegistry[resolvedToolId] : undefined - const toolParams: Record = toolCfg?.params || {} - const toolOutputs: Record = toolCfg?.outputs - ? Object.fromEntries( - Object.entries(toolCfg.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) - ) - : {} - const filteredToolParams: Record = {} - for (const [k, v] of Object.entries(toolParams)) { - if (!(k in blockInputs) && !hiddenParamKeys.has(k)) filteredToolParams[k] = v - } - operations[opId] = { - toolId: resolvedToolId, - toolName: toolCfg?.name || resolvedToolId, - description: toolCfg - ? getCopilotToolDescription(toolCfg, { - isHosted, - fallbackName: resolvedToolId, - }) - : undefined, - inputs: { ...filteredToolParams, ...(operationInputs[opId] || {}) }, - outputs: toolOutputs, - inputSchema: operationParameters[opId] || [], - } - } - - const filteredOutputs = blockConfig.outputs - ? Object.fromEntries( - Object.entries(blockConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) - ) - : undefined - - metadata = { - id: blockId, - name: blockConfig.name || blockId, - description: blockConfig.longDescription || blockConfig.description || '', - bestPractices: blockConfig.bestPractices, - inputSchema: commonParameters, - inputDefinitions: blockInputs, - triggerAllowed: !!blockConfig.triggerAllowed, - authType: resolveAuthType(blockConfig.authMode), - tools, - triggers, - operationInputSchema: operationParameters, - operations, - outputs: filteredOutputs, - } } try { @@ -379,9 +225,7 @@ export const getBlocksMetadataServerTool: BaseServerTool< }) } - if (metadata) { - result[blockId] = removeNullish(metadata) as CopilotBlockMetadata - } + result[blockId] = metadata } const transformedResult: Record = {} @@ -689,85 +533,6 @@ function generateInputExample(schema: CopilotSubblockMetadata, inputDef?: any): return undefined } } - -function processSubBlock(sb: any): CopilotSubblockMetadata { - const processed: CopilotSubblockMetadata = { - id: sb.id, - type: sb.type, - } - - const optionalFields = { - title: sb.title, - required: sb.required, - description: sb.description, - placeholder: sb.placeholder, - layout: sb.layout, - mode: sb.mode, - hidden: sb.hidden, - canonicalParamId: sb.canonicalParamId, - defaultValue: sb.defaultValue, - - // Numeric constraints - min: sb.min, - max: sb.max, - step: sb.step, - integer: sb.integer, - - // Text input properties - rows: sb.rows, - password: sb.password, - multiSelect: sb.multiSelect, - - // Code/generation properties - language: sb.language, - generationType: sb.generationType, - - // OAuth/credential properties - serviceId: sb.serviceId, - requiredScopes: sb.requiredScopes, - - // File properties - mimeType: sb.mimeType, - acceptedTypes: sb.acceptedTypes, - multiple: sb.multiple, - maxSize: sb.maxSize, - - // Other properties - connectionDroppable: sb.connectionDroppable, - columns: sb.columns, - wandConfig: sb.wandConfig, - availableTriggers: sb.availableTriggers, - triggerProvider: sb.triggerProvider, - dependsOn: sb.dependsOn, - } - - // Add non-null optional fields - for (const [key, value] of Object.entries(optionalFields)) { - if (value !== undefined && value !== null) { - ;(processed as any)[key] = value - } - } - - // Handle condition normalization - const condition = normalizeCondition(sb.condition) - if (condition !== undefined) { - processed.condition = condition - } - - // Handle value field (check if it's a function) - if (typeof sb.value === 'function') { - processed.value = 'function' - } - - // Process options with icon detection - const options = resolveSubblockOptions(sb) - if (options) { - processed.options = options - } - - return processed -} - function resolveAuthType( authMode: AuthMode | undefined ): 'OAuth' | 'API Key' | 'Bot Token' | undefined { @@ -777,150 +542,6 @@ function resolveAuthType( if (authMode === AuthMode.BotToken) return 'Bot Token' return undefined } - -/** - * Gets all available models from PROVIDER_DEFINITIONS as static options. - * This provides fallback data when store state is not available server-side. - * Excludes dynamic providers (ollama, ollama-cloud, vllm, openrouter, fireworks) which require runtime fetching. - */ -function getStaticModelOptions(): { id: string; label?: string }[] { - const models: { id: string; label?: string }[] = [] - - for (const provider of Object.values(PROVIDER_DEFINITIONS)) { - // Skip providers with dynamic/fetched models - if ( - provider.id === 'ollama' || - provider.id === 'ollama-cloud' || - provider.id === 'vllm' || - provider.id === 'openrouter' || - provider.id === 'fireworks' || - provider.id === 'together' || - provider.id === 'baseten' - ) { - continue - } - if (provider?.models) { - for (const model of provider.models) { - // Exclude retired models — the agent must not receive a model whose API - // calls fail (mirrors the user picker + VFS menu). - if (model.sunset?.status === 'deprecated') continue - models.push({ id: model.id, label: model.id }) - } - } - } - - return models -} - -/** - * Attempts to call a dynamic options function with fallback data injected. - * When the function accesses store state that's unavailable server-side, - * this provides static fallback data from known sources. - * - * @param optionsFn - The options function to call - * @returns Options array or undefined if options cannot be resolved - */ -function callOptionsWithFallback( - optionsFn: () => any[] -): { id: string; label?: string; hasIcon?: boolean }[] | undefined { - // Get static model data to use as fallback - const staticModels = getStaticModelOptions() - - // Create a mock providers state with static data - const mockProvidersState = { - providers: { - base: { models: staticModels.map((m) => m.id) }, - ollama: { models: [] }, - 'ollama-cloud': { models: [] }, - vllm: { models: [] }, - litellm: { models: [] }, - openrouter: { models: [] }, - fireworks: { models: [] }, - together: { models: [] }, - baseten: { models: [] }, - }, - } - - // Store original getState if it exists - let originalGetState: (() => any) | undefined - let store: any - - try { - // Try to get the providers store module - // eslint-disable-next-line @typescript-eslint/no-require-imports - store = require('@/stores/providers') - if (store?.useProvidersStore?.getState) { - originalGetState = store.useProvidersStore.getState - // Temporarily replace getState with our mock - store.useProvidersStore.getState = () => mockProvidersState - } - } catch { - // Store module not available, continue with mock - } - - try { - const result = optionsFn() - return result - } finally { - // Restore original getState - if (store?.useProvidersStore && originalGetState) { - store.useProvidersStore.getState = originalGetState - } - } -} - -function resolveSubblockOptions( - sb: any -): { id: string; label?: string; hasIcon?: boolean }[] | undefined { - // Skip if subblock uses fetchOptions (async network calls) - if (sb.fetchOptions) { - return undefined - } - - let rawOptions: any[] | undefined - - try { - if (typeof sb.options === 'function') { - // Try calling with fallback data injection for store-dependent options - rawOptions = callOptionsWithFallback(sb.options) - } else { - rawOptions = sb.options - } - } catch { - // Options function failed even with fallback, skip - return undefined - } - - if (!Array.isArray(rawOptions) || rawOptions.length === 0) { - return undefined - } - - const normalized = rawOptions - .map((opt: any) => { - if (!opt) return undefined - - const id = typeof opt === 'object' ? opt.id : opt - if (id === undefined || id === null) return undefined - - const result: { id: string; label?: string; hasIcon?: boolean } = { - id: String(id), - } - - if (typeof opt === 'object' && typeof opt.label === 'string') { - result.label = opt.label - } - - if (typeof opt === 'object' && opt.icon) { - result.hasIcon = true - } - - return result - }) - .filter((o): o is { id: string; label?: string; hasIcon?: boolean } => o !== undefined) - - return normalized.length > 0 ? normalized : undefined -} - function removeNullish(obj: any): any { if (!obj || typeof obj !== 'object') return obj @@ -934,168 +555,6 @@ function removeNullish(obj: any): any { return cleaned } - -function normalizeCondition(condition: any): any | undefined { - try { - if (!condition) return undefined - if (typeof condition === 'function') { - return condition() - } - return condition - } catch { - return undefined - } -} - -function splitParametersByOperation( - subBlocks: any[], - blockInputsForDescriptions?: Record -): { - commonParameters: CopilotSubblockMetadata[] - operationParameters: Record -} { - const commonParameters: CopilotSubblockMetadata[] = [] - const operationParameters: Record = {} - - for (const sb of subBlocks || []) { - const cond = normalizeCondition(sb.condition) - const processed = processSubBlock(sb) - - if (cond && cond.field === 'operation' && !cond.not && cond.value !== undefined) { - const values: any[] = Array.isArray(cond.value) ? cond.value : [cond.value] - for (const v of values) { - const key = String(v) - if (!operationParameters[key]) operationParameters[key] = [] - operationParameters[key].push(processed) - } - } else { - // Override description from inputDefinitions if available (by id or canonicalParamId) - if (blockInputsForDescriptions) { - const candidates = [sb.id, sb.canonicalParamId].filter(Boolean) - for (const key of candidates) { - const bi = (blockInputsForDescriptions as any)[key as string] - if (bi && typeof bi.description === 'string') { - processed.description = bi.description - break - } - } - } - commonParameters.push(processed) - } - } - - return { commonParameters, operationParameters } -} - -function getCopilotHiddenParamKeys(blockConfig: BlockConfig): Set { - const hiddenParamKeys = new Set() - for (const subBlock of blockConfig.subBlocks ?? []) { - if (!subBlock.hideFromCopilot) continue - if (subBlock.id) hiddenParamKeys.add(subBlock.id) - if (subBlock.canonicalParamId) hiddenParamKeys.add(subBlock.canonicalParamId) - } - return hiddenParamKeys -} - -export function computeBlockLevelInputs( - blockConfig: BlockConfig, - hiddenParamKeys = getCopilotHiddenParamKeys(blockConfig) -): Record { - const inputs = blockConfig.inputs || {} - const subBlocks: any[] = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter( - (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' - ) - : [] - - const byParamKey: Record = {} - for (const sb of subBlocks) { - if (sb.id) { - byParamKey[sb.id] = byParamKey[sb.id] || [] - byParamKey[sb.id].push(sb) - } - if (sb.canonicalParamId) { - byParamKey[sb.canonicalParamId] = byParamKey[sb.canonicalParamId] || [] - byParamKey[sb.canonicalParamId].push(sb) - } - } - - const blockInputs: Record = {} - for (const key of Object.keys(inputs)) { - if (hiddenParamKeys.has(key)) continue - const sbs = byParamKey[key] || [] - const isOperationGated = sbs.some((sb) => { - const cond = normalizeCondition(sb.condition) - return cond && cond.field === 'operation' && !cond.not && cond.value !== undefined - }) - if (!isOperationGated) { - blockInputs[key] = inputs[key] - } - } - - return blockInputs -} - -function computeOperationLevelInputs( - blockConfig: BlockConfig -): Record> { - const inputs = blockConfig.inputs || {} - const subBlocks = Array.isArray(blockConfig.subBlocks) - ? blockConfig.subBlocks.filter( - (sb) => !sb.hideFromCopilot && sb.mode !== 'trigger' && sb.mode !== 'trigger-advanced' - ) - : [] - - const opInputs: Record> = {} - - for (const sb of subBlocks) { - const cond = normalizeCondition(sb.condition) - if (!cond || cond.field !== 'operation' || cond.not) continue - const keys: string[] = [] - if (sb.canonicalParamId) keys.push(sb.canonicalParamId) - if (sb.id) keys.push(sb.id) - const values = Array.isArray(cond.value) ? cond.value : [cond.value] - for (const key of keys) { - if (!(key in inputs)) continue - for (const v of values) { - const op = String(v) - if (!opInputs[op]) opInputs[op] = {} - opInputs[op][key] = inputs[key] - } - } - } - - return opInputs -} - -function resolveOperationIds( - blockConfig: BlockConfig, - operationParameters: Record -): string[] { - const opBlock = (blockConfig.subBlocks || []).find((sb) => sb.id === 'operation') - if (opBlock && Array.isArray(opBlock.options)) { - const ids = opBlock.options.map((o) => o.id).filter(Boolean) - if (ids.length > 0) return ids - } - return Object.keys(operationParameters) -} - -function resolveToolIdForOperation(blockConfig: BlockConfig, opId: string): string | undefined { - try { - const toolSelector = blockConfig.tools?.config?.tool - if (typeof toolSelector === 'function') { - const maybeToolId = toolSelector({ operation: opId }) - if (typeof maybeToolId === 'string') return maybeToolId - } - } catch (error) { - const toolLogger = createLogger('GetBlocksMetadataServerTool') - toolLogger.warn('Failed to resolve tool ID for operation', { - error: toError(error).message, - }) - } - return undefined -} - const DOCS_FILE_MAPPING: Record = {} const SPECIAL_BLOCKS_METADATA: Record = { diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 46220afdb91..bfe7560a19d 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -23,6 +23,7 @@ import { PROVIDER_DEFINITIONS, SIM_AUTO_MODEL_ID, } from '@/providers/models' +import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key' import type { ToolConfig, ToolHostingCondition } from '@/tools/types' /** The service-account alternative to OAuth for a service, when it offers one. */ @@ -1095,7 +1096,10 @@ export function serializeIntegrationSchema( // field and load it" matches the callable tool and the block's tools.access. id: tool.id, name: tool.name, - description: getCopilotToolDescription(tool, { isHosted: hosted }), + description: getCopilotToolDescription(tool, { + isHosted: hosted, + hostedApiKey: deriveHostedApiKeySupport(tool.hosting), + }), version: tool.version, auth, oauth: diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 7dcdc507dbf..b399663cf4a 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -1,6 +1,5 @@ import type { Principal } from '@sim/auth/principal' import { getBlockVisibility } from '@/lib/core/config/block-visibility' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, @@ -11,6 +10,7 @@ import { type TokenServiceAccountField, } from '@/lib/credentials/token-service-accounts/descriptors' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { allowedIntegrationTypes, principalUserId } from '@/lib/integrations/principal-scope.server' import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, @@ -18,8 +18,6 @@ import { SLACK_CUSTOM_BOT_PROVIDER_ID, } from '@/lib/oauth/types' import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export interface CredentialProviderAuthorizationOption { providerId: string @@ -215,27 +213,6 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript throw new Error(`Service-account provider ${providerId} is missing its canonical descriptor`) } -function principalUserId(principal: Principal): string | undefined { - if (principal.kind === 'session' || principal.kind === 'personal_api_key') { - return principal.userId - } - if (principal.kind === 'delegated') return principal.subjectUserId - return undefined -} - -async function allowedIntegrationTypes( - principal: Principal, - workspaceId: string -): Promise | null> { - const userId = principalUserId(principal) - const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null - const integrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null -} - export async function listCredentialProviderCatalog( principal: Principal, context: CredentialProviderCatalogContext diff --git a/apps/sim/lib/integrations/principal-scope.server.ts b/apps/sim/lib/integrations/principal-scope.server.ts new file mode 100644 index 00000000000..f7d0a6b993d --- /dev/null +++ b/apps/sim/lib/integrations/principal-scope.server.ts @@ -0,0 +1,56 @@ +import type { Principal } from '@sim/auth/principal' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +/** + * The workspace integration gate, shared by every catalog that projects + * caller-specific integration availability. + * + * It exists as one module because it has two independent consumers — the + * credential-provider catalog and the block/tool catalog — and the interesting + * case is the one that is easy to get wrong twice: a workspace API key carries + * no user, so there is no permission group to read, and the allowlist collapses + * to the deployment's own. Two copies of that reasoning would disagree the + * first time either changed, and the two endpoints describe the same + * integrations. + * + * Server-only: `getUserPermissionConfig` reads the database. + */ + +/** + * The human whose permission groups apply, or `undefined` when the principal is + * not user-bearing. + * + * A workspace API key authorizes as the workspace itself, independently of who + * created it, so there is deliberately no fallback to a key owner: substituting + * one would apply a bystander's permission groups to every caller of that key. + */ +export function principalUserId(principal: Principal): string | undefined { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + if (principal.kind === 'delegated') return principal.subjectUserId + return undefined +} + +/** + * Lowercased block types this principal may see in this workspace, or `null` + * when nothing restricts them. + * + * The intersection of the caller's permission-group allowlist with the + * deployment's `ALLOWED_INTEGRATIONS`. A principal with no user contributes no + * permission-group half, leaving the deployment allowlist alone. + */ +export async function allowedIntegrationTypes( + principal: Principal, + workspaceId: string +): Promise | null> { + const userId = principalUserId(principal) + const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null + const integrations = intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null +} diff --git a/apps/sim/tools/generated/tool-metadata.ts b/apps/sim/tools/generated/tool-metadata.ts index f2e960199f7..6d6fc709488 100644 --- a/apps/sim/tools/generated/tool-metadata.ts +++ b/apps/sim/tools/generated/tool-metadata.ts @@ -3,7 +3,7 @@ /** Serializable metadata for every built-in tool, keyed by tool id. */ const toolMetadata: Record = JSON.parse( - '{"a2a_cancel_task":{"id":"a2a_cancel_task","name":"A2A Cancel Task","description":"Request cancellation of an in-progress A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to cancel"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_agent_card":{"id":"a2a_get_agent_card","name":"A2A Get Agent Card","description":"Fetch the Agent Card (discovery document) for an external A2A agent.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_get_task":{"id":"a2a_get_task","name":"A2A Get Task","description":"Retrieve the current state and result of an A2A task.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The task ID to retrieve"},"historyLength":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of history messages to include"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"a2a_send_message":{"id":"a2a_send_message","name":"A2A Send Message","description":"Send a message to an external A2A agent and return its response.","version":"1.0.0","params":{"agentUrl":{"type":"string","required":true,"visibility":"user-only","description":"The A2A agent endpoint URL"},"message":{"type":"string","required":true,"visibility":"user-or-llm","description":"The message text to send"},"data":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional structured JSON data to attach"},"files":{"type":"json","required":false,"visibility":"user-or-llm","description":"Optional files to attach"},"taskId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Existing task ID to continue"},"contextId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversation context ID to continue"},"apiKey":{"type":"string","required":false,"visibility":"user-only","description":"API key for authentication (if required)"}}},"agentmail_create_draft":{"id":"agentmail_create_draft","name":"Create Draft","description":"Create a new email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to create the draft in"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"inReplyTo":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of message being replied to"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_create_inbox":{"id":"agentmail_create_inbox","name":"Create Inbox","description":"Create a new email inbox with AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"username":{"type":"string","required":false,"visibility":"user-or-llm","description":"Username for the inbox email address"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Domain for the inbox email address"},"displayName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Display name for the inbox"}}},"agentmail_delete_draft":{"id":"agentmail_delete_draft","name":"Delete Draft","description":"Delete an email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to delete"}}},"agentmail_delete_inbox":{"id":"agentmail_delete_inbox","name":"Delete Inbox","description":"Delete an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to delete"}}},"agentmail_delete_thread":{"id":"agentmail_delete_thread","name":"Delete Thread","description":"Delete an email thread in AgentMail (moves to trash, or permanently deletes if already in trash)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to delete"},"permanent":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Force permanent deletion instead of moving to trash"}}},"agentmail_forward_message":{"id":"agentmail_forward_message","name":"Forward Message","description":"Forward an email message to new recipients in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to forward"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional plain text to prepend"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"Additional HTML to prepend"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_get_draft":{"id":"agentmail_get_draft","name":"Get Draft","description":"Get details of a specific email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox the draft belongs to"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to retrieve"}}},"agentmail_get_inbox":{"id":"agentmail_get_inbox","name":"Get Inbox","description":"Get details of a specific email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to retrieve"}}},"agentmail_get_message":{"id":"agentmail_get_message","name":"Get Message","description":"Get details of a specific email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to retrieve"}}},"agentmail_get_thread":{"id":"agentmail_get_thread","name":"Get Thread","description":"Get details of a specific email thread including messages in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to retrieve"}}},"agentmail_list_drafts":{"id":"agentmail_list_drafts","name":"List Drafts","description":"List email drafts in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list drafts from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of drafts to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_inboxes":{"id":"agentmail_list_inboxes","name":"List Inboxes","description":"List all email inboxes in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of inboxes to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_messages":{"id":"agentmail_list_messages","name":"List Messages","description":"List messages in an inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list messages from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of messages to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"}}},"agentmail_list_threads":{"id":"agentmail_list_threads","name":"List Threads","description":"List email threads in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to list threads from"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of threads to return"},"pageToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token for next page of results"},"labels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to filter threads by"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter threads after this ISO 8601 timestamp"}}},"agentmail_reply_message":{"id":"agentmail_reply_message","name":"Reply to Message","description":"Reply to an existing email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to reply from"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to reply to"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text reply body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML reply body"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Override recipient email addresses (comma-separated)"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC email addresses (comma-separated)"},"replyAll":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reply to all recipients of the original message"}}},"agentmail_send_draft":{"id":"agentmail_send_draft","name":"Send Draft","description":"Send an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to send"}}},"agentmail_send_message":{"id":"agentmail_send_message","name":"Send Message","description":"Send an email message from an AgentMail inbox","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to send from"},"to":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient email address (comma-separated for multiple)"},"subject":{"type":"string","required":true,"visibility":"user-or-llm","description":"Email subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text email body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML email body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"}}},"agentmail_update_draft":{"id":"agentmail_update_draft","name":"Update Draft","description":"Update an existing email draft in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the draft"},"draftId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the draft to update"},"to":{"type":"string","required":false,"visibility":"user-or-llm","description":"Recipient email addresses (comma-separated)"},"subject":{"type":"string","required":false,"visibility":"user-or-llm","description":"Draft subject line"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Plain text draft body"},"html":{"type":"string","required":false,"visibility":"user-or-llm","description":"HTML draft body"},"cc":{"type":"string","required":false,"visibility":"user-or-llm","description":"CC recipient email addresses (comma-separated)"},"bcc":{"type":"string","required":false,"visibility":"user-or-llm","description":"BCC recipient email addresses (comma-separated)"},"sendAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to schedule sending"}}},"agentmail_update_inbox":{"id":"agentmail_update_inbox","name":"Update Inbox","description":"Update the display name of an email inbox in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox to update"},"displayName":{"type":"string","required":true,"visibility":"user-or-llm","description":"New display name for the inbox"}}},"agentmail_update_message":{"id":"agentmail_update_message","name":"Update Message","description":"Add or remove labels on an email message in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the message"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the message"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the message"}}},"agentmail_update_thread":{"id":"agentmail_update_thread","name":"Update Thread Labels","description":"Add or remove labels on an email thread in AgentMail","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentMail API key"},"inboxId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the inbox containing the thread"},"threadId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the thread to update"},"addLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to add to the thread"},"removeLabels":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated labels to remove from the thread"}}},"agentphone_create_call":{"id":"agentphone_create_call","name":"Create Outbound Call","description":"Initiate an outbound voice call from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent that will handle the call"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number to call in E.164 format (e.g. +14155551234)"},"fromNumberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to use as caller ID. Must belong to the agent. If omitted, the agent\'s first assigned number is used."},"initialGreeting":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional greeting spoken when the recipient answers"},"voice":{"type":"string","required":false,"visibility":"user-or-llm","description":"Voice ID override for this call (defaults to the agent\'s configured voice)"},"systemPrompt":{"type":"string","required":false,"visibility":"user-or-llm","description":"When provided, uses a built-in LLM for the conversation instead of forwarding to your webhook"}}},"agentphone_create_contact":{"id":"agentphone_create_contact","name":"Create Contact","description":"Create a new contact in AgentPhone","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"phoneNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Phone number in E.164 format (e.g. +14155551234)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact\'s full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Contact\'s email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Freeform notes stored on the contact"}}},"agentphone_create_number":{"id":"agentphone_create_number","name":"Create Phone Number","description":"Provision a new SMS- and voice-enabled phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code (e.g. US, CA). Defaults to US."},"areaCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Preferred area code (US/CA only, e.g. \\"415\\"). Best-effort — may be ignored if unavailable."},"agentId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optionally attach the number to an agent immediately"}}},"agentphone_delete_contact":{"id":"agentphone_delete_contact","name":"Delete Contact","description":"Delete a contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_call":{"id":"agentphone_get_call","name":"Get Call","description":"Fetch a call and its full transcript","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve"}}},"agentphone_get_call_transcript":{"id":"agentphone_get_call_transcript","name":"Get Call Transcript","description":"Get the full ordered transcript for a call","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"callId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the call to retrieve the transcript for"}}},"agentphone_get_contact":{"id":"agentphone_get_contact","name":"Get Contact","description":"Fetch a single contact by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"}}},"agentphone_get_conversation":{"id":"agentphone_get_conversation","name":"Get Conversation","description":"Get a conversation along with its recent messages","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"messageLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of recent messages to include (default 50, max 100)"}}},"agentphone_get_conversation_messages":{"id":"agentphone_get_conversation_messages","name":"Get Conversation Messages","description":"Get paginated messages for a conversation","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_number_messages":{"id":"agentphone_get_number_messages","name":"Get Phone Number Messages","description":"Fetch messages received on a specific phone number","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of messages to return (default 50, max 200)"},"before":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received before this ISO 8601 timestamp"},"after":{"type":"string","required":false,"visibility":"user-or-llm","description":"Return messages received after this ISO 8601 timestamp"}}},"agentphone_get_usage":{"id":"agentphone_get_usage","name":"Get Usage","description":"Retrieve current usage statistics for the AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"}}},"agentphone_get_usage_daily":{"id":"agentphone_get_usage_daily","name":"Get Daily Usage","description":"Get a daily breakdown of usage (messages, calls, webhooks) for the last N days","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"days":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of days to return (1-365, default 30)"}}},"agentphone_get_usage_monthly":{"id":"agentphone_get_usage_monthly","name":"Get Monthly Usage","description":"Get monthly usage aggregation (messages, calls, webhooks) for the last N months","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"months":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of months to return (1-24, default 6)"}}},"agentphone_list_calls":{"id":"agentphone_list_calls","name":"List Calls","description":"List voice calls for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"},"status":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by status (completed, in-progress, failed)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by direction (inbound, outbound)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by call type (pstn, web)"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search by phone number (matches fromNumber or toNumber)"}}},"agentphone_list_contacts":{"id":"agentphone_list_contacts","name":"List Contacts","description":"List contacts for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter by name or phone number (case-insensitive contains)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 50, max 200)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_conversations":{"id":"agentphone_list_conversations","name":"List Conversations","description":"List conversations (message threads) for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_list_numbers":{"id":"agentphone_list_numbers","name":"List Phone Numbers","description":"List all phone numbers provisioned for this AgentPhone account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to return (default 20, max 100)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip (min 0)"}}},"agentphone_react_to_message":{"id":"agentphone_react_to_message","name":"React to Message","description":"Send an iMessage tapback reaction to a message (iMessage only)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"messageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the message to react to"},"reaction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Reaction type: love, like, dislike, laugh, emphasize, or question"}}},"agentphone_release_number":{"id":"agentphone_release_number","name":"Release Phone Number","description":"Release (delete) a phone number. This action is irreversible.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"numberId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the phone number to release"}}},"agentphone_send_message":{"id":"agentphone_send_message","name":"Send Message","description":"Send an outbound SMS or iMessage from an AgentPhone agent","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"agentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Agent sending the message"},"toNumber":{"type":"string","required":true,"visibility":"user-or-llm","description":"Recipient phone number in E.164 format (e.g. +14155551234)"},"body":{"type":"string","required":true,"visibility":"user-or-llm","description":"Message text to send"},"mediaUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"Optional URL of an image, video, or file to attach"},"numberId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Phone number ID to send from. If omitted, the agent\'s first assigned number is used."}}},"agentphone_update_contact":{"id":"agentphone_update_contact","name":"Update Contact","description":"Update a contact\'s fields","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"contactId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Contact ID"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"New phone number in E.164 format"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New contact name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"New email address"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"New freeform notes"}}},"agentphone_update_conversation":{"id":"agentphone_update_conversation","name":"Update Conversation","description":"Update conversation metadata (stored state). Pass null to clear existing metadata.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"AgentPhone API key"},"conversationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Conversation ID"},"metadata":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom key-value metadata to store on the conversation. Pass null to clear existing metadata."}}},"agiloft_async_status":{"id":"agiloft_async_status","name":"Agiloft Async Status","description":"Check whether an asynchronous Agiloft call, such as a run action button, has completed.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table the asynchronous call was made against"},"callbackId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Callback ID returned by the asynchronous call, e.g. from Run Action Button"}}},"agiloft_attach_file":{"id":"agiloft_attach_file","name":"Agiloft Attach File","description":"Attach a file to a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to attach the file to"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"file":{"type":"file","required":true,"visibility":"user-or-llm","description":"File to attach"},"fileName":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name to assign to the file (defaults to original file name)"},"overwrite":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Replace the contents of the field instead of adding another file to it"}}},"agiloft_attachment_info":{"id":"agiloft_attachment_info","name":"Agiloft Attachment Info","description":"Get information about file attachments on a record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to check attachments on"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field to inspect"}}},"agiloft_create_record":{"id":"agiloft_create_record","name":"Agiloft Create Record","description":"Create a new record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record field values as a JSON object (e.g., {\\"first_name\\": \\"John\\", \\"status\\": \\"Active\\"})"}}},"agiloft_delete_record":{"id":"agiloft_delete_record","name":"Agiloft Delete Record","description":"Delete a record from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to delete"},"substituteIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER."},"deleteRule":{"type":"string","required":false,"visibility":"user-or-llm","description":"How to treat records that depend on this one: ERROR_IF_DEPENDANTS (default — fails rather than cascading), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER"}}},"agiloft_get_choice_line_id":{"id":"agiloft_get_choice_line_id","name":"Agiloft Get Choice Line ID","description":"Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"case\\", \\"contracts\\")"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice field name (e.g., \\"priority\\", \\"status\\")"},"value":{"type":"string","required":true,"visibility":"user-or-llm","description":"Choice display value to resolve (e.g., \\"High\\", \\"Active\\")"}}},"agiloft_list_tables":{"id":"agiloft_list_tables","name":"Agiloft List Tables","description":"List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":false,"visibility":"user-or-llm","description":"Logical name of a single table to describe (e.g., \\"contacts\\"). Leave empty to list every table in the knowledge base."},"includeLinkedInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Include the source table and column behind each linked field"},"skipColumnsInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Return table names only, omitting field details, for a much smaller response"}}},"agiloft_lock_record":{"id":"agiloft_lock_record","name":"Agiloft Lock Record","description":"Lock, unlock, or check the lock status of an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to lock, unlock, or check"},"lockAction":{"type":"string","required":true,"visibility":"user-or-llm","description":"Action to perform: \\"lock\\", \\"unlock\\", or \\"check\\""},"force":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Unlock only: release a lock held by another user."}}},"agiloft_nlp_search":{"id":"agiloft_nlp_search","name":"Agiloft Natural Language Search","description":"Search Agiloft records by describing what you want in plain language, such as \\"active NDAs submitted last month\\".","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"nlpQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The request in plain language, e.g. \\"Show me open, high-priority contracts\\". Structured field filters are not accepted — use Search Records for those."},"fields":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated field names to return, e.g. \\"id, contract_title1, company_name\\""},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number, starting from 0"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Records per page"}}},"agiloft_read_record":{"id":"agiloft_read_record","name":"Agiloft Read Record","description":"Read a record by ID from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to read"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the response"}}},"agiloft_remove_attachment":{"id":"agiloft_remove_attachment","name":"Agiloft Remove Attachment","description":"Remove an attached file from a field in an Agiloft record.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file to remove (starting from 0)"}}},"agiloft_retrieve_attachment":{"id":"agiloft_retrieve_attachment","name":"Agiloft Retrieve Attachment","description":"Download an attached file from an Agiloft record field.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record containing the attachment"},"fieldName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the attachment field"},"position":{"type":"string","required":true,"visibility":"user-or-llm","description":"Position index of the file in the field (starting from 0)"}}},"agiloft_run_action_button":{"id":"agiloft_run_action_button","name":"Agiloft Run Action Button","description":"Run an action button on an Agiloft record, such as an approval or send-for-signature step.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"case\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to run the action button on"},"actionButtonField":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical name of the field holding the action button (e.g., \\"ab_field\\")"}}},"agiloft_saved_search":{"id":"agiloft_saved_search","name":"Agiloft Saved Search","description":"List the saved searches defined for an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Logical table name to list saved searches for (e.g., \\"contract\\")"}}},"agiloft_search_records":{"id":"agiloft_search_records","name":"Agiloft Search Records","description":"Search for records in an Agiloft table using a query.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name to search in (e.g., \\"contracts\\", \\"contacts.employees\\")"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Ad hoc EWSearch query. Combine conditions with && (and) or || (or) and quote every value — e.g. \\"summary~=\'test\'&&priority=\'High\'\\". Required unless a saved search is given."},"search":{"type":"string","required":false,"visibility":"user-or-llm","description":"Label of a saved search defined on the table (e.g., \\"C: Status is Closed\\"). Can be combined with a query to narrow it further."},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of field names to include in the results"},"page":{"type":"string","required":false,"visibility":"user-or-llm","description":"Page number for paginated results (starting from 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return per page. Agiloft treats 0 as \\"all records\\", so leave it unset or use a positive value to keep result sizes bounded."}}},"agiloft_select_records":{"id":"agiloft_select_records","name":"Agiloft Select Records","description":"Select record IDs matching a SQL WHERE clause from an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"where":{"type":"string","required":true,"visibility":"user-or-llm","description":"SQL WHERE clause using database column names (e.g., \\"summary like \'%new%\'\\" or \\"assigned_person=\'John Doe\'\\"). EWSelect has no page size and returns every matching ID, so append a database limit such as \\"limit 0,200\\" to bound the result."}}},"agiloft_update_record":{"id":"agiloft_update_record","name":"Agiloft Update Record","description":"Update an existing record in an Agiloft table.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the record to update"},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Updated field values as a JSON object (e.g., {\\"status\\": \\"Active\\", \\"priority\\": \\"High\\"})"}}},"agiloft_upsert_record":{"id":"agiloft_upsert_record","name":"Agiloft Upsert Record","description":"Create an Agiloft record, or update it when a record already matches the given fields.","version":"1.0.0","params":{"instanceUrl":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft instance URL (e.g., https://mycompany.agiloft.com)"},"knowledgeBase":{"type":"string","required":true,"visibility":"user-only","description":"Knowledge base name"},"login":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft username"},"password":{"type":"string","required":true,"visibility":"user-only","description":"Agiloft password"},"table":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table name (e.g., \\"contracts\\", \\"contacts.employees\\")"},"match":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field used to find an existing record (e.g., \\"ext_id\\"). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict."},"async":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result."},"data":{"type":"string","required":true,"visibility":"user-or-llm","description":"Field values as a JSON object. On create these populate the new record; on update only the supplied fields change."}}},"ahrefs_anchors":{"id":"ahrefs_anchors","name":"Ahrefs Anchors","description":"Get the anchor text distribution for a target domain or URL\'s backlinks, showing how many links and referring domains use each anchor text.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks":{"id":"ahrefs_backlinks","name":"Ahrefs Backlinks","description":"Get a list of backlinks pointing to a target domain or URL. Returns details about each backlink including source URL, anchor text, and domain rating.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live backlinks), \\"all_time\\" (default, includes lost backlinks), or \\"since:YYYY-MM-DD\\" (backlinks found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_backlinks_stats":{"id":"ahrefs_backlinks_stats","name":"Ahrefs Backlinks Stats","description":"Get backlink and referring domain totals for a target domain or URL, both currently live and across all time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_batch_analysis":{"id":"ahrefs_batch_analysis","name":"Ahrefs Batch Analysis","description":"Get bulk SEO metrics (Domain Rating, backlinks, referring domains, organic traffic, and more) for multiple domains or URLs in a single request. Useful for comparing many competitors at once.","version":"1.0.0","params":{"targets":{"type":"string","required":true,"visibility":"user-or-llm","description":"Comma-separated list of domains or URLs to analyze. Example: \\"example.com,competitor.com\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode applied to every target: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"protocol":{"type":"string","required":false,"visibility":"user-or-llm","description":"Protocol applied to every target: \\"both\\" (default), \\"http\\", or \\"https\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_broken_backlinks":{"id":"ahrefs_broken_backlinks","name":"Ahrefs Broken Backlinks","description":"Get a list of broken backlinks pointing to a target domain or URL. Useful for identifying link reclamation opportunities.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating":{"id":"ahrefs_domain_rating","name":"Ahrefs Domain Rating","description":"Get the Domain Rating (DR) and Ahrefs Rank for a target domain. Domain Rating shows the strength of a website\'s backlink profile on a scale from 0 to 100.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze (e.g., example.com)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date for historical data in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_domain_rating_history":{"id":"ahrefs_domain_rating_history","name":"Ahrefs Domain Rating History","description":"Get the historical Domain Rating (DR) trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keyword_overview":{"id":"ahrefs_keyword_overview","name":"Ahrefs Keyword Overview","description":"Get detailed metrics for a keyword including search volume, keyword difficulty, CPC, clicks, and traffic potential.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The keyword to analyze"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_keywords_history":{"id":"ahrefs_keywords_history","name":"Ahrefs Keywords History","description":"Get the historical organic keyword ranking distribution for a target domain or URL over a date range: how many keywords rank in each position bucket at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics":{"id":"ahrefs_metrics","name":"Ahrefs Metrics","description":"Get a one-call organic and paid search overview for a target domain or URL: organic traffic, organic keywords, paid traffic, paid keywords, and estimated traffic cost.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_metrics_history":{"id":"ahrefs_metrics_history","name":"Ahrefs Metrics History","description":"Get the historical organic and paid traffic trend for a target domain or URL over a date range: organic traffic/cost and paid traffic/cost at each point in time.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_competitors":{"id":"ahrefs_organic_competitors","name":"Ahrefs Organic Competitors","description":"Get domains that compete with a target domain or URL for the same organic keywords, ranked by keyword overlap.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_organic_keywords":{"id":"ahrefs_organic_keywords","name":"Ahrefs Organic Keywords","description":"Get organic keywords that a target domain or URL ranks for in Google search results. Returns keyword details including search volume, ranking position, and estimated traffic.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for search results. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_paid_pages":{"id":"ahrefs_paid_pages","name":"Ahrefs Paid Pages","description":"Get a target domain\'s pages that receive paid search traffic, sorted by estimated paid traffic. Returns page URLs with their paid traffic, keyword counts, and estimated spend.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_overview":{"id":"ahrefs_rank_tracker_competitors_overview","name":"Ahrefs Rank Tracker Competitors Overview","description":"Get competitor rankings for the keywords tracked in an Ahrefs Rank Tracker project: each tracked keyword\'s volume and difficulty alongside every competitor\'s position, traffic, and traffic value. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_competitors_stats":{"id":"ahrefs_rank_tracker_competitors_stats","name":"Ahrefs Rank Tracker Competitors Stats","description":"Get aggregate competitor stats for an Ahrefs Rank Tracker project: each competitor\'s traffic, traffic value, average position, and share of voice across all tracked keywords. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report metrics for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_overview":{"id":"ahrefs_rank_tracker_overview","name":"Ahrefs Rank Tracker Overview","description":"Get ranking overview metrics for the keywords tracked in an Ahrefs Rank Tracker project: position, search volume, keyword difficulty, and estimated traffic. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":true,"visibility":"user-only","description":"Date to report rankings for, in YYYY-MM-DD format"},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"dateCompared":{"type":"string","required":false,"visibility":"user-only","description":"Comparison date in YYYY-MM-DD format, to compute position/traffic deltas"},"volumeMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search volume calculation: \\"monthly\\" or \\"average\\" (default: \\"monthly\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_rank_tracker_serp_overview":{"id":"ahrefs_rank_tracker_serp_overview","name":"Ahrefs Rank Tracker SERP Overview","description":"Get the full SERP (search engine results page) for a keyword tracked in an Ahrefs Rank Tracker project, including every ranking URL with its position, title, and authority metrics. This endpoint is free and does not consume API units.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Rank Tracker project ID (found in the project URL in Ahrefs)"},"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The tracked keyword to retrieve SERP data for"},"country":{"type":"string","required":true,"visibility":"user-or-llm","description":"Country code for the tracked keyword. Example: \\"us\\", \\"gb\\", \\"de\\""},"device":{"type":"string","required":true,"visibility":"user-or-llm","description":"Rankings device type: \\"desktop\\" or \\"mobile\\""},"topPositions":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of top organic positions to return (defaults to all available)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Timestamp to return the last available SERP Overview at, in YYYY-MM-DDThh:mm:ss format"},"locationId":{"type":"number","required":false,"visibility":"user-or-llm","description":"Location ID of the tracked keyword, if tracked at a specific location"},"languageCode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code of the tracked keyword"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_refdomains_history":{"id":"ahrefs_refdomains_history","name":"Ahrefs Referring Domains History","description":"Get the historical referring domains trend for a target domain or URL over a date range, grouped daily, weekly, or monthly.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\""},"dateFrom":{"type":"string","required":true,"visibility":"user-only","description":"Start date of the historical period, in YYYY-MM-DD format"},"dateTo":{"type":"string","required":false,"visibility":"user-only","description":"End date of the historical period, in YYYY-MM-DD format (defaults to today)"},"historyGrouping":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval for grouping data points: \\"daily\\", \\"weekly\\", or \\"monthly\\" (default: \\"monthly\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_referring_domains":{"id":"ahrefs_referring_domains","name":"Ahrefs Referring Domains","description":"Get a list of domains that link to a target domain or URL. Returns unique referring domains with their domain rating, backlink counts, and discovery dates.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain or URL to analyze. Example: \\"example.com\\" or \\"https://example.com/page\\""},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"history":{"type":"string","required":false,"visibility":"user-or-llm","description":"Historical scope: \\"live\\" (currently live), \\"all_time\\" (default, includes lost domains), or \\"since:YYYY-MM-DD\\" (domains found since a date)."},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_related_terms":{"id":"ahrefs_related_terms","name":"Ahrefs Related Terms","description":"Get keyword ideas related to a seed keyword: terms the same top-ranking pages also rank for (\\"also rank for\\") or also discuss (\\"also talk about\\"), with volume, difficulty, and CPC.","version":"1.0.0","params":{"keyword":{"type":"string","required":true,"visibility":"user-or-llm","description":"The seed keyword to find related terms for"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for keyword data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"terms":{"type":"string","required":false,"visibility":"user-or-llm","description":"Type of related keywords to return: \\"also_rank_for\\", \\"also_talk_about\\", or \\"all\\" (default: \\"all\\")"},"viewFor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Whether to derive related terms from the top 10 or top 100 ranking pages (default: \\"top_10\\")"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_site_audit_page_explorer":{"id":"ahrefs_site_audit_page_explorer","name":"Ahrefs Site Audit Page Explorer","description":"Get crawled pages from an Ahrefs Site Audit project with health and SEO metrics: HTTP status, title, link counts, backlinks, indexability, and traffic. Optionally filter to pages affected by a specific issue.","version":"1.0.0","params":{"projectId":{"type":"number","required":true,"visibility":"user-or-llm","description":"The Site Audit project ID (found in the project URL in Ahrefs)"},"date":{"type":"string","required":false,"visibility":"user-only","description":"Crawl date in YYYY-MM-DDThh:mm:ss format (defaults to the most recent crawl)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of results to skip, for pagination"},"issueId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Only return pages affected by this issue ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"ahrefs_top_pages":{"id":"ahrefs_top_pages","name":"Ahrefs Top Pages","description":"Get the top pages of a target domain sorted by organic traffic. Returns page URLs with their traffic, keyword counts, and estimated traffic value.","version":"1.0.0","params":{"target":{"type":"string","required":true,"visibility":"user-or-llm","description":"The target domain to analyze. Example: \\"example.com\\""},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Country code for traffic data. Example: \\"us\\", \\"gb\\", \\"de\\" (default: \\"us\\")"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Analysis mode: domain (entire domain), prefix (URL prefix), subdomains (include all subdomains, default), exact (exact URL match). Example: \\"domain\\""},"date":{"type":"string","required":false,"visibility":"user-only","description":"Date to report metrics on, in YYYY-MM-DD format (defaults to today)"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of results to return. Example: 50 (default: 1000)"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ahrefs API Key"}}},"airtable_create_records":{"id":"airtable_create_records","name":"Airtable Create Records","description":"Write new records to an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to create, each with a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_delete_records":{"id":"airtable_delete_records","name":"Airtable Delete Records","description":"Delete one or more records from an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordIds":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of record IDs to delete (each starts with \\"rec\\", e.g., [\\"recXXXXXXXXXXXXXX\\"]). Pass a single-element array to delete one record."}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_base_schema":{"id":"airtable_get_base_schema","name":"Airtable Get Base Schema","description":"Get the schema of all tables, fields, and views in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_get_record":{"id":"airtable_get_record","name":"Airtable Get Record","description":"Retrieve a single record from an Airtable table by its ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to retrieve (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_bases":{"id":"airtable_list_bases","name":"Airtable List Bases","description":"List all bases the authenticated user has access to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination offset for retrieving additional bases"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_records":{"id":"airtable_list_records","name":"Airtable List Records","description":"Read records from an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"maxRecords":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of records to return (default: all records)"},"filterFormula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Formula to filter records (e.g., \\"({Field Name} = \'Value\')\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_list_tables":{"id":"airtable_list_tables","name":"Airtable List Tables","description":"List all tables and their schema in an Airtable base","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_multiple_records":{"id":"airtable_update_multiple_records","name":"Airtable Update Multiple Records","description":"Update multiple existing records in an Airtable table","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to update, each with an `id` and a `fields` object"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_update_record":{"id":"airtable_update_record","name":"Airtable Update Record","description":"Update an existing record in an Airtable table by ID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"recordId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Record ID to update (starts with \\"rec\\", e.g., \\"recXXXXXXXXXXXXXX\\")"},"fields":{"type":"json","required":true,"visibility":"user-or-llm","description":"An object containing the field names and their new values"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airtable_upsert_records":{"id":"airtable_upsert_records","name":"Airtable Upsert Records","description":"Update existing records or create new ones in an Airtable table, matching on the specified merge fields","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token"},"baseId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Airtable base ID (starts with \\"app\\", e.g., \\"appXXXXXXXXXXXXXX\\")"},"tableId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Table ID (starts with \\"tbl\\") or table name"},"records":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of records to upsert, each with a `fields` object"},"fieldsToMergeOn":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of field names used to match existing records (max 3). A record is updated when all merge fields match, otherwise it is created. Example: [\\"Name\\"]"},"typecast":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, Airtable automatically converts string values to the field type"}},"oauth":{"required":true,"provider":"airtable"}},"airweave_search":{"id":"airweave_search","name":"Airweave Search","description":"Search your synced data collections using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Airweave API Key for authentication"},"collectionId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The readable ID of the collection to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query text"},"limit":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 100)"},"retrievalStrategy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retrieval strategy: hybrid (default), neural, or keyword"},"expandQuery":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate query variations to improve recall"},"rerank":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Reorder results for improved relevance using LLM"},"generateAnswer":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Generate a natural-language answer to the query"}}},"algolia_add_record":{"id":"algolia_add_record","name":"Algolia Add Record","description":"Add or replace a record in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":false,"visibility":"user-or-llm","description":"Object ID for the record (auto-generated if not provided)"},"record":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object representing the record to add"}}},"algolia_batch_operations":{"id":"algolia_batch_operations","name":"Algolia Batch Operations","description":"Perform batch add, update, partial update, or delete operations on records in an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of batch operations. Each item has \\"action\\" (addObject, updateObject, partialUpdateObject, partialUpdateObjectNoCreate, deleteObject, delete, clear) and \\"body\\" (the record data; must include objectID for update/delete; use an empty object {} for the index-level delete/clear actions)"}}},"algolia_browse_records":{"id":"algolia_browse_records","name":"Algolia Browse Records","description":"Browse and iterate over all records in an Algolia index using cursor pagination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key (must have browse ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to browse"},"query":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search query to filter browsed records"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string to narrow down results"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 1000, max: 1000)"},"cursor":{"type":"string","required":false,"visibility":"user-or-llm","description":"Cursor from a previous browse response for pagination"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_clear_records":{"id":"algolia_clear_records","name":"Algolia Clear Records","description":"Clear all records from an Algolia index while keeping settings, synonyms, and rules","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to clear"}}},"algolia_copy_move_index":{"id":"algolia_copy_move_index","name":"Algolia Copy/Move Index","description":"Copy or move an Algolia index to a new destination","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the source index"},"operation":{"type":"string","required":true,"visibility":"user-or-llm","description":"Operation to perform: \\"copy\\" or \\"move\\""},"destination":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the destination index"},"scope":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of scopes to copy (only for \\"copy\\" operation): [\\"settings\\", \\"synonyms\\", \\"rules\\"]. Omit to copy everything including records."}}},"algolia_delete_by_filter":{"id":"algolia_delete_by_filter","name":"Algolia Delete By Filter","description":"Delete all records matching a filter from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter expression to match records for deletion (e.g., \\"category:outdated\\")"},"facetFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of facet filters (e.g., [\\"brand:Acme\\"])"},"numericFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of numeric filters (e.g., [\\"price > 100\\"])"},"tagFilters":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of tag filters using the _tags attribute (e.g., [\\"published\\"])"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search filter (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search filter"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search filter"}}},"algolia_delete_index":{"id":"algolia_delete_index","name":"Algolia Delete Index","description":"Delete an entire Algolia index and all its records","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have deleteIndex ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to delete"}}},"algolia_delete_record":{"id":"algolia_delete_record","name":"Algolia Delete Record","description":"Delete a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to delete"}}},"algolia_get_record":{"id":"algolia_get_record","name":"Algolia Get Record","description":"Get a record by objectID from an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to retrieve"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"}}},"algolia_get_records":{"id":"algolia_get_records","name":"Algolia Get Records","description":"Retrieve multiple records by objectID from one or more Algolia indices","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Default index name for all requests"},"requests":{"type":"json","required":true,"visibility":"user-or-llm","description":"Array of objects specifying records to retrieve. Each must have \\"objectID\\" and optionally \\"indexName\\" and \\"attributesToRetrieve\\"."}}},"algolia_get_settings":{"id":"algolia_get_settings","name":"Algolia Get Settings","description":"Retrieve the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"}}},"algolia_get_task_status":{"id":"algolia_get_task_status","name":"Algolia Get Task Status","description":"Check whether an Algolia indexing task has finished publishing","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index the task ran against"},"taskID":{"type":"number","required":true,"visibility":"user-or-llm","description":"The taskID returned by a previous write operation"}}},"algolia_list_indices":{"id":"algolia_list_indices","name":"Algolia List Indices","description":"List all indices in an Algolia application","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for paginating indices (default: not paginated)"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of indices per page (default: 100)"}}},"algolia_partial_update_record":{"id":"algolia_partial_update_record","name":"Algolia Partial Update Record","description":"Partially update a record in an Algolia index without replacing it entirely","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"objectID":{"type":"string","required":true,"visibility":"user-or-llm","description":"The objectID of the record to update"},"attributes":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with attributes to update. Supports built-in operations like {\\"stock\\": {\\"_operation\\": \\"Decrement\\", \\"value\\": 1}}"},"createIfNotExists":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to create the record if it does not exist (default: true)"}}},"algolia_search":{"id":"algolia_search","name":"Algolia Search","description":"Search an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia API Key"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index to search"},"query":{"type":"string","required":true,"visibility":"user-or-llm","description":"Search query text"},"hitsPerPage":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of hits per page (default: 20)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number to retrieve (default: 0)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter string (e.g., \\"category:electronics AND price < 100\\")"},"attributesToRetrieve":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of attributes to retrieve"},"facets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of facet attribute names to retrieve counts for (use \\"*\\" for all)"},"getRankingInfo":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to include detailed ranking information in each hit"},"aroundLatLng":{"type":"string","required":false,"visibility":"user-or-llm","description":"Coordinates for geo-search (e.g., \\"40.71,-74.01\\")"},"aroundRadius":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum radius in meters for geo-search, or \\"all\\" for unlimited"},"insideBoundingBox":{"type":"json","required":false,"visibility":"user-or-llm","description":"Bounding box coordinates as [[lat1, lng1, lat2, lng2]] for geo-search"},"insidePolygon":{"type":"json","required":false,"visibility":"user-or-llm","description":"Polygon coordinates as [[lat1, lng1, lat2, lng2, lat3, lng3, ...]] for geo-search"}}},"algolia_update_settings":{"id":"algolia_update_settings","name":"Algolia Update Settings","description":"Update the settings of an Algolia index","version":"1.0","params":{"applicationId":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Application ID"},"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Algolia Admin API Key (must have editSettings ACL)"},"indexName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the Algolia index"},"settings":{"type":"json","required":true,"visibility":"user-or-llm","description":"JSON object with settings to update (e.g., {\\"searchableAttributes\\": [\\"name\\", \\"description\\"], \\"customRanking\\": [\\"desc(popularity)\\"]})"},"forwardToReplicas":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to apply changes to replica indices (default: false)"}}},"amplitude_event_segmentation":{"id":"amplitude_event_segmentation","name":"Amplitude Event Segmentation","description":"Query event analytics data with segmentation. Get event counts, uniques, averages, and more.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Event type name to analyze"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: uniques, totals, pct_dau, average, histogram, sums, value_avg, or formula (default: uniques)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (prefix custom user properties with \\"gp:\\")"},"groupBy2":{"type":"string","required":false,"visibility":"user-or-llm","description":"Second property name to group by (prefix custom user properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (max 1000)"},"filters":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON array of filter objects applied to the event, e.g. [{\\"subprop_type\\":\\"event\\",\\"subprop_key\\":\\"city\\",\\"subprop_op\\":\\"is\\",\\"subprop_value\\":[\\"San Francisco\\"]}]"},"formula":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when metric is \\"formula\\", e.g. \\"UNIQUES(A)/UNIQUES(B)\\""},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_funnels":{"id":"amplitude_funnels","name":"Amplitude Funnels","description":"Analyze conversion rates and drop-off between a sequence of events.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"events":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON array of event objects, one per funnel step in order, e.g. [{\\"event_type\\":\\"signup\\"},{\\"event_type\\":\\"purchase\\"}]"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"mode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Funnel ordering: \\"ordered\\", \\"unordered\\", or \\"sequential\\" (default: ordered)"},"userType":{"type":"string","required":false,"visibility":"user-or-llm","description":"User type: \\"new\\" or \\"active\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: -300000 (real-time), -3600000 (hourly), 1 (daily), 7 (weekly), or 30 (monthly)"},"conversionWindowSeconds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Conversion window in seconds (default: 2592000, i.e. 30 days)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of group-by values (default: 100, max: 1000)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_active_users":{"id":"amplitude_get_active_users","name":"Amplitude Get Active Users","description":"Get active or new user counts over a date range from the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric type: \\"active\\" or \\"new\\" (default: active)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_get_revenue":{"id":"amplitude_get_revenue","name":"Amplitude Get Revenue","description":"Get revenue LTV data including ARPU, ARPPU, total revenue, and paying user counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"metric":{"type":"string","required":false,"visibility":"user-or-llm","description":"Metric: 0 (ARPU), 1 (ARPPU), 2 (Total Revenue), 3 (Paying Users)"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property name to group by (limit: one)"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_group_identify":{"id":"amplitude_group_identify","name":"Amplitude Group Identify","description":"Set group-level properties in Amplitude. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"groupType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Group classification (e.g., \\"company\\", \\"org_id\\")"},"groupValue":{"type":"string","required":true,"visibility":"user-or-llm","description":"Specific group identifier (e.g., \\"Acme Corp\\")"},"groupProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of group properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_identify_user":{"id":"amplitude_identify_user","name":"Amplitude Identify User","description":"Set user properties in Amplitude using the Identify API. Supports $set, $setOnce, $add, $append, $unset operations.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"userProperties":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON object of user properties. Use operations like $set, $setOnce, $add, $append, $unset."},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_list_events":{"id":"amplitude_list_events","name":"Amplitude List Events","description":"List all event types in the Amplitude project with their weekly totals and unique counts.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_realtime_active_users":{"id":"amplitude_realtime_active_users","name":"Amplitude Real-time Active Users","description":"Get real-time active user counts at 5-minute granularity for the last 2 days.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_retention":{"id":"amplitude_retention","name":"Amplitude Retention","description":"Measure how many users return to perform an action after a starting action.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"startEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON starting event object, e.g. {\\"event_type\\":\\"_new\\"} or {\\"event_type\\":\\"_active\\"}"},"returnEvent":{"type":"string","required":true,"visibility":"user-or-llm","description":"JSON returning event object, e.g. {\\"event_type\\":\\"_all\\"} or {\\"event_type\\":\\"_active\\"}"},"start":{"type":"string","required":true,"visibility":"user-or-llm","description":"Start date in YYYYMMDD format"},"end":{"type":"string","required":true,"visibility":"user-or-llm","description":"End date in YYYYMMDD format"},"retentionMode":{"type":"string","required":false,"visibility":"user-or-llm","description":"Retention type: \\"bracket\\", \\"rolling\\", or \\"n-day\\" (default: n-day)"},"retentionBrackets":{"type":"string","required":false,"visibility":"user-or-llm","description":"Required when Retention Mode is \\"bracket\\". Day ranges, e.g. [[0,4]]"},"interval":{"type":"string","required":false,"visibility":"user-or-llm","description":"Time interval: 1 (daily), 7 (weekly), or 30 (monthly)"},"groupBy":{"type":"string","required":false,"visibility":"user-or-llm","description":"Property to group by (limit: one; prefix custom properties with \\"gp:\\")"},"segment":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON segment definition(s) applied to the query"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_send_event":{"id":"amplitude_send_event","name":"Amplitude Send Event","description":"Track an event in Amplitude using the HTTP V2 API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"User ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"eventType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the event (e.g., \\"page_view\\", \\"purchase\\")"},"eventProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of custom event properties"},"userProperties":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON object of user properties to set (supports $set, $setOnce, $add, $append, $unset)"},"time":{"type":"string","required":false,"visibility":"user-or-llm","description":"Event timestamp in milliseconds since epoch"},"sessionId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Session start time in milliseconds since epoch"},"insertId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Unique ID for deduplication (within 7-day window)"},"appVersion":{"type":"string","required":false,"visibility":"user-or-llm","description":"Application version string"},"platform":{"type":"string","required":false,"visibility":"user-or-llm","description":"Platform (e.g., \\"Web\\", \\"iOS\\", \\"Android\\")"},"country":{"type":"string","required":false,"visibility":"user-or-llm","description":"Two-letter country code"},"language":{"type":"string","required":false,"visibility":"user-or-llm","description":"Language code (e.g., \\"en\\")"},"ip":{"type":"string","required":false,"visibility":"user-or-llm","description":"IP address for geo-location"},"price":{"type":"string","required":false,"visibility":"user-or-llm","description":"Price of the item purchased"},"quantity":{"type":"string","required":false,"visibility":"user-or-llm","description":"Quantity of items purchased"},"revenue":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue amount"},"productId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Product identifier"},"revenueType":{"type":"string","required":false,"visibility":"user-or-llm","description":"Revenue type (e.g., \\"purchase\\", \\"refund\\")"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_activity":{"id":"amplitude_user_activity","name":"Amplitude User Activity","description":"Get the event stream for a specific user by their Amplitude ID.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"amplitudeId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Amplitude internal user ID"},"offset":{"type":"string","required":false,"visibility":"user-or-llm","description":"Offset for pagination (default 0)"},"limit":{"type":"string","required":false,"visibility":"user-or-llm","description":"Maximum number of events to return (default 1000, max 1000)"},"direction":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort direction: \\"latest\\" or \\"earliest\\" (default: latest)"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"amplitude_user_profile":{"id":"amplitude_user_profile","name":"Amplitude User Profile","description":"Get a user profile including properties, cohort memberships, and computed properties. Not available for EU data-residency projects.","version":"1.0.0","params":{"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"userId":{"type":"string","required":false,"visibility":"user-or-llm","description":"External user ID (required if no device_id)"},"deviceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Device ID (required if no user_id)"},"getAmpProps":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include Amplitude user properties (true/false, default: false)"},"getCohortIds":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include cohort IDs the user belongs to (true/false, default: false)"},"getComputations":{"type":"string","required":false,"visibility":"user-or-llm","description":"Include computed user properties (true/false, default: false)"}}},"amplitude_user_search":{"id":"amplitude_user_search","name":"Amplitude User Search","description":"Search for a user by User ID, Device ID, or Amplitude ID using the Dashboard REST API.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude API Key"},"secretKey":{"type":"string","required":true,"visibility":"user-only","description":"Amplitude Secret Key"},"user":{"type":"string","required":true,"visibility":"user-or-llm","description":"User ID, Device ID, or Amplitude ID to search for"},"dataResidency":{"type":"string","required":false,"visibility":"user-or-llm","description":"Data residency region: \\"us\\" (default) or \\"eu\\""}}},"apify_get_dataset_items":{"id":"apify_get_dataset_items","name":"APIFY Get Dataset Items","description":"Retrieve items stored in an APIFY dataset","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"datasetId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Dataset ID to read items from. Example: \\"9RnD3Pql2vGZkc5H5\\""},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max items to return (1-250000). Default: all items. Example: 500"},"offset":{"type":"number","required":false,"visibility":"user-or-llm","description":"Number of items to skip at the start. Default: 0"},"fields":{"type":"string","required":false,"visibility":"user-or-llm","description":"Comma-separated list of fields to include. Example: \\"title,url,price\\""}}},"apify_get_run":{"id":"apify_get_run","name":"APIFY Get Run","description":"Get the status and details of an APIFY actor run","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"runId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor run ID to fetch. Example: \\"HG7ML7M8z78YcAPEB\\""}}},"apify_run_actor_async":{"id":"apify_run_actor_async","name":"APIFY Run Actor (Async)","description":"Run an APIFY actor asynchronously with polling for long-running tasks","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"waitForFinish":{"type":"number","required":false,"visibility":"user-or-llm","description":"Initial wait time in seconds (0-60) before polling starts. Example: 30"},"itemLimit":{"type":"number","required":false,"default":100,"visibility":"user-or-llm","description":"Max dataset items to fetch (1-250000). Default: 100. Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_actor_sync":{"id":"apify_run_actor_sync","name":"APIFY Run Actor (Sync)","description":"Run an APIFY actor synchronously and get results (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"actorId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Actor ID or username/actor-name. Examples: \\"apify/web-scraper\\", \\"janedoe/my-actor\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor input as JSON string. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}], \\"maxPages\\": 10}"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the actor run (128-32768). Example: 1024 for 1GB, 2048 for 2GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the actor run. Example: 300 for 5 minutes, 3600 for 1 hour"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\", \\"build-tag-name\\""}}},"apify_run_task":{"id":"apify_run_task","name":"APIFY Run Task","description":"Run a saved APIFY actor task synchronously and get dataset items (max 5 minutes)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"APIFY API token from console.apify.com/account#/integrations"},"taskId":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task ID or username/task-name. Examples: \\"janedoe/my-task\\", \\"moJRLRc85AitArpNN\\""},"input":{"type":"string","required":false,"visibility":"user-or-llm","description":"JSON string that overrides the task\'s saved input. Example: {\\"startUrls\\": [{\\"url\\": \\"https://example.com\\"}]}"},"itemLimit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Max dataset items to return (1-250000). Example: 500"},"memory":{"type":"number","required":false,"visibility":"user-or-llm","description":"Memory in megabytes allocated for the run (128-32768). Example: 1024 for 1GB"},"timeout":{"type":"number","required":false,"visibility":"user-or-llm","description":"Timeout in seconds for the run. Example: 300 for 5 minutes"},"build":{"type":"string","required":false,"visibility":"user-or-llm","description":"Actor build to run. Examples: \\"latest\\", \\"beta\\", \\"1.2.3\\""}}},"apollo_account_bulk_create":{"id":"apollo_account_bulk_create","name":"Apollo Bulk Create Accounts","description":"Create up to 100 accounts at once in your Apollo database. Set run_dedupe=true to deduplicate by domain, organization_id, and name. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"accounts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of accounts to create (max 100). Each account should include a name, and may optionally include domain, phone, phone_status_cd, raw_address, owner_id, linkedin_url, facebook_url, twitter_url, salesforce_id, and hubspot_id."},"append_label_names":{"type":"array","required":false,"visibility":"user-only","description":"Array of label names to add to ALL accounts in this request"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"When true, performs aggressive deduplication by domain, organization_id, and name (defaults to false)"}}},"apollo_account_bulk_update":{"id":"apollo_account_bulk_update","name":"Apollo Bulk Update Accounts","description":"Update up to 1000 existing accounts at once in your Apollo database (higher limit than contacts!). Each account must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"account_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of account IDs to update with the same values (max 1000). Use with name/owner_id for uniform updates. Use either this OR account_attributes."},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this name to all accounts"},"owner_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this owner to all accounts"},"account_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"When using account_ids, apply this account stage to all accounts"},"account_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of account objects with individual updates (each must include id). Example: [{\\"id\\": \\"acc1\\", \\"name\\": \\"Acme\\", \\"owner_id\\": \\"u1\\", \\"account_stage_id\\": \\"s1\\", \\"typed_custom_fields\\": {\\"field_id\\": \\"value\\"}}]"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, processes the update asynchronously. Only supported when using account_ids; returns 422 if used with account_attributes."}}},"apollo_account_create":{"id":"apollo_account_create","name":"Apollo Create Account","description":"Create a new account (company) in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain without www. prefix (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the account"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_account_search":{"id":"apollo_account_search","name":"Apollo Search Accounts","description":"Search your team\'s accounts in Apollo. Display limit: 50,000 records (100 records per page, 500 pages max). Use filters to narrow results. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter accounts by organization name (partial-match search)"},"account_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account stage IDs"},"account_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by account label IDs"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"account_last_activity_date\\", \\"account_created_at\\", or \\"account_updated_at\\""},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Sort ascending when true. Defaults to descending."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_account_update":{"id":"apollo_account_update","name":"Apollo Update Account","description":"Update an existing account in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the account to update (e.g., \\"acc_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name (e.g., \\"Acme Corporation\\")"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"acme.com\\")"},"phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company phone number"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo user ID of the account owner"},"account_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"Apollo ID for the account stage to assign this account to"},"raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate location (e.g., \\"San Francisco, CA, USA\\")"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_contact_bulk_create":{"id":"apollo_contact_bulk_create","name":"Apollo Bulk Create Contacts","description":"Create up to 100 contacts at once in your Apollo database. Supports deduplication to prevent creating duplicate contacts. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contacts":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contacts to create (max 100). Each contact may include first_name, last_name, email, title, organization_name, account_id, owner_id, contact_stage_id, linkedin_url, phone (single string) or phone_numbers (array of {raw_number, position}), contact_emails, typed_custom_fields, and CRM IDs (salesforce_contact_id, hubspot_id, team_id) for cross-system matching"},"append_label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Label names to add to all contacts in this request (e.g., [\\"Hot Lead\\"])"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"Enable deduplication to prevent creating duplicate contacts. When true, existing contacts are returned without modification"}}},"apollo_contact_bulk_update":{"id":"apollo_contact_bulk_update","name":"Apollo Bulk Update Contacts","description":"Update up to 100 existing contacts at once in your Apollo database. Each contact must include an id field. Master key required.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to update. Must be paired with an object-form contact_attributes specifying the fields to apply uniformly to all listed contacts."},"contact_attributes":{"type":"json","required":false,"visibility":"user-or-llm","description":"Required. Either an array of per-contact updates (each with id) — used standalone — or a single object of attributes to apply to all contact_ids. Supported fields: owner_id, email, organization_name, title, first_name, last_name, account_id, present_raw_address, linkedin_url, typed_custom_fields"},"async":{"type":"boolean","required":false,"visibility":"user-only","description":"Force asynchronous processing. Automatically enabled for >100 contacts"}}},"apollo_contact_create":{"id":"apollo_contact_create","name":"Apollo Create Contact","description":"Create a new contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the contact"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID to associate with (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for POST /contacts)"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"},"run_dedupe":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, Apollo deduplicates against existing contacts"}}},"apollo_contact_search":{"id":"apollo_contact_search","name":"Apollo Search Contacts","description":"Search your team\'s contacts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"contact_stage_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by contact stage IDs"},"contact_label_ids":{"type":"array","required":false,"visibility":"user-only","description":"Filter by Apollo label IDs (lists)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-only","description":"Sort field: contact_last_activity_date, contact_email_last_opened_at, contact_email_last_clicked_at, contact_created_at, or contact_updated_at"},"sort_ascending":{"type":"boolean","required":false,"visibility":"user-only","description":"When true, sort ascending. Must be used together with sort_by_field"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_contact_update":{"id":"apollo_contact_update","name":"Apollo Update Contact","description":"Update an existing contact in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"contact_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the contact to update (e.g., \\"con_abc123\\")"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the contact"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the contact"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address"},"title":{"type":"string","required":false,"visibility":"user-or-llm","description":"Job title (e.g., \\"VP of Sales\\", \\"Software Engineer\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo account ID (e.g., \\"acc_abc123\\")"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the contact owner (accepted by Apollo but not officially documented for PATCH /contacts/{id})"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the contact\'s employer (e.g., \\"Apollo\\")"},"website_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"Corporate website URL (e.g., \\"https://www.apollo.io/\\")"},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Lists/labels to add the contact to (e.g., [\\"Prospects\\"])"},"contact_stage_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the contact stage"},"present_raw_address":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal location for the contact (e.g., \\"Atlanta, United States\\")"},"direct_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number"},"corporate_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Work/office phone number"},"mobile_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Mobile phone number"},"home_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Home phone number"},"other_phone":{"type":"string","required":false,"visibility":"user-or-llm","description":"Alternative phone number"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-or-llm","description":"Custom field values keyed by custom field ID"}}},"apollo_email_accounts":{"id":"apollo_email_accounts","name":"Apollo Get Email Accounts","description":"Get list of team\'s linked email accounts in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"}}},"apollo_opportunity_create":{"id":"apollo_opportunity_create","name":"Apollo Create Opportunity","description":"Create a new deal for an account in your Apollo database (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"account_id":{"type":"string","required":false,"visibility":"user-or-llm","description":"ID of the account this opportunity belongs to (e.g., \\"acc_abc123\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_opportunity_get":{"id":"apollo_opportunity_get","name":"Apollo Get Opportunity","description":"Retrieve complete details of a specific deal/opportunity by ID","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to retrieve (e.g., \\"opp_abc123\\")"}}},"apollo_opportunity_search":{"id":"apollo_opportunity_search","name":"Apollo Search Opportunities","description":"Search and list all deals/opportunities in your team\'s Apollo account","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"amount\\", \\"is_closed\\", or \\"is_won\\""},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_opportunity_update":{"id":"apollo_opportunity_update","name":"Apollo Update Opportunity","description":"Update an existing deal/opportunity in your Apollo database","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"opportunity_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the opportunity to update (e.g., \\"opp_abc123\\")"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Name of the opportunity/deal (e.g., \\"Enterprise License - Q1\\")"},"amount":{"type":"string","required":false,"visibility":"user-or-llm","description":"Monetary value as a plain number string with no commas or currency symbols"},"opportunity_stage_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the opportunity stage"},"owner_id":{"type":"string","required":false,"visibility":"user-only","description":"User ID of the opportunity owner"},"closed_date":{"type":"string","required":false,"visibility":"user-or-llm","description":"Expected close date in YYYY-MM-DD format"},"typed_custom_fields":{"type":"json","required":false,"visibility":"user-only","description":"Custom field values as { custom_field_id: value } map"}}},"apollo_organization_bulk_enrich":{"id":"apollo_organization_bulk_enrich","name":"Apollo Bulk Organization Enrichment","description":"Enrich data for up to 10 organizations at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domains":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of company domains to enrich (max 10, no www. or @, e.g., [\\"apollo.io\\", \\"stripe.com\\"])"}}},"apollo_organization_enrich":{"id":"apollo_organization_enrich","name":"Apollo Organization Enrichment","description":"Enrich data for a single organization using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"domain":{"type":"string","required":true,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"}}},"apollo_organization_search":{"id":"apollo_organization_search","name":"Apollo Organization Search","description":"Search Apollo\'s database for companies using filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company HQ locations (cities, US states, or countries)"},"organization_not_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Exclude companies whose HQ is in these locations"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges as \\"min,max\\" strings (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"q_organization_keyword_tags":{"type":"array","required":false,"visibility":"user-or-llm","description":"Industry or keyword tags"},"q_organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Organization name to search for (e.g., \\"Acme\\", \\"TechCorp\\")"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to include (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Domain names to filter by (no www. or @, up to 1,000)"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_people_bulk_enrich":{"id":"apollo_people_bulk_enrich","name":"Apollo Bulk People Enrichment","description":"Enrich data for up to 10 people at once using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"people":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of people to enrich (max 10)"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_enrich":{"id":"apollo_people_enrich","name":"Apollo People Enrichment","description":"Enrich data for a single person using Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"first_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"First name of the person"},"last_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Last name of the person"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Full name of the person (alternative to first_name/last_name)"},"id":{"type":"string","required":false,"visibility":"user-or-llm","description":"Apollo ID for the person"},"hashed_email":{"type":"string","required":false,"visibility":"user-or-llm","description":"MD5 or SHA-256 hashed email"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Email address of the person"},"organization_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company name where the person works"},"domain":{"type":"string","required":false,"visibility":"user-or-llm","description":"Company domain (e.g., \\"apollo.io\\", \\"acme.com\\")"},"linkedin_url":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"reveal_personal_emails":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal personal email addresses (uses credits)"},"reveal_phone_number":{"type":"boolean","required":false,"visibility":"user-only","description":"Reveal phone numbers (uses credits, requires webhook_url)"},"webhook_url":{"type":"string","required":false,"visibility":"user-only","description":"Webhook URL for async phone number delivery (required when reveal_phone_number is true)"}}},"apollo_people_search":{"id":"apollo_people_search","name":"Apollo People Search","description":"Search Apollo\'s database for people using demographic filters","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key"},"person_titles":{"type":"array","required":false,"visibility":"user-or-llm","description":"Job titles to search for (e.g., [\\"CEO\\", \\"VP of Sales\\"])"},"include_similar_titles":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Whether to return people with job titles similar to person_titles"},"person_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Locations to search in (e.g., [\\"San Francisco, CA\\", \\"New York, NY\\"])"},"person_seniorities":{"type":"array","required":false,"visibility":"user-or-llm","description":"Seniority levels (one of: owner, founder, c_suite, partner, vp, head, director, manager, senior, entry, intern)"},"organization_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Apollo organization IDs to filter by (e.g., [\\"5e66b6381e05b4008c8331b8\\"])"},"organization_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Company names to search within (legacy filter)"},"organization_locations":{"type":"array","required":false,"visibility":"user-or-llm","description":"Headquarters locations of the people\'s current employer (e.g., [\'texas\', \'tokyo\', \'spain\'])"},"q_organization_domains_list":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employer domain names (e.g., [\\"apollo.io\\", \\"microsoft.com\\"]) — up to 1,000, no www. or @"},"organization_num_employees_ranges":{"type":"array","required":false,"visibility":"user-or-llm","description":"Employee count ranges for the person\'s current employer. Each entry is \\"min,max\\" (e.g., [\\"1,10\\", \\"250,500\\", \\"10000,20000\\"])"},"contact_email_status":{"type":"array","required":false,"visibility":"user-or-llm","description":"Email statuses to filter by: \\"verified\\", \\"unverified\\", \\"likely to engage\\", \\"unavailable\\""},"q_keywords":{"type":"string","required":false,"visibility":"user-or-llm","description":"Keywords to search for"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination, default 1 (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, default 25, max 100 (e.g., 25, 50, 100)"}}},"apollo_sequence_add_contacts":{"id":"apollo_sequence_add_contacts","name":"Apollo Add Contacts to Sequence","description":"Add contacts to an Apollo sequence","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sequence_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the sequence to add contacts to (e.g., \\"seq_abc123\\")"},"contact_ids":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of contact IDs to add to the sequence (e.g., [\\"con_abc123\\", \\"con_def456\\"]). Either contact_ids or label_names must be provided."},"label_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of label names to identify contacts to add to the sequence. Either contact_ids or label_names must be provided."},"send_email_from_email_account_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the email account to send from. Use the Get Email Accounts operation to look this up."},"send_email_from_email_address":{"type":"string","required":false,"visibility":"user-only","description":"Specific email address to send from within the email account."},"sequence_no_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they have no email address"},"sequence_unverified_email":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts with unverified email addresses"},"sequence_job_change":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who recently changed jobs"},"sequence_active_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts active in other campaigns"},"sequence_finished_in_other_campaigns":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts who finished other campaigns"},"sequence_same_company_in_same_campaign":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if others from the same company are in the sequence"},"contacts_without_ownership_permission":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts without ownership permission"},"add_if_in_queue":{"type":"boolean","required":false,"visibility":"user-only","description":"Add contacts even if they are in the queue"},"contact_verification_skipped":{"type":"boolean","required":false,"visibility":"user-only","description":"Skip contact verification when adding"},"user_id":{"type":"string","required":false,"visibility":"user-only","description":"ID of the user performing the action"},"status":{"type":"string","required":false,"visibility":"user-only","description":"Initial status for added contacts: \\"active\\" or \\"paused\\""},"auto_unpause_at":{"type":"string","required":false,"visibility":"user-only","description":"ISO 8601 datetime to automatically unpause contacts"}}},"apollo_sequence_search":{"id":"apollo_sequence_search","name":"Apollo Search Sequences","description":"Search for sequences/campaigns in your team\'s Apollo account (master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"q_name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Search sequences by name (e.g., \\"Outbound Q1\\", \\"Follow-up\\")"},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"apollo_task_create":{"id":"apollo_task_create","name":"Apollo Create Task","description":"Create one or more tasks in Apollo (one task per contact_id, master key required)","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"user_id":{"type":"string","required":true,"visibility":"user-or-llm","description":"ID of the Apollo user the task is assigned to"},"contact_ids":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of contact IDs. One task is created per contact."},"priority":{"type":"string","required":false,"visibility":"user-or-llm","description":"Task priority: \\"high\\", \\"medium\\", or \\"low\\" (defaults to \\"medium\\")"},"due_at":{"type":"string","required":true,"visibility":"user-or-llm","description":"Due date/time in ISO 8601 format (e.g., \\"2024-12-31T23:59:59Z\\")"},"type":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task type: \\"call\\", \\"outreach_manual_email\\", \\"linkedin_step_connect\\", \\"linkedin_step_message\\", \\"linkedin_step_view_profile\\", \\"linkedin_step_interact_post\\", or \\"action_item\\""},"status":{"type":"string","required":true,"visibility":"user-or-llm","description":"Task status: \\"scheduled\\", \\"completed\\", or \\"skipped\\""},"note":{"type":"string","required":false,"visibility":"user-or-llm","description":"Free-form note providing context for the task"}}},"apollo_task_search":{"id":"apollo_task_search","name":"Apollo Search Tasks","description":"Search for tasks in Apollo","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Apollo API key (master key required)"},"sort_by_field":{"type":"string","required":false,"visibility":"user-or-llm","description":"Sort field: \\"task_due_at\\" or \\"task_priority\\""},"open_factor_names":{"type":"array","required":false,"visibility":"user-or-llm","description":"Filter by status. Common values: [\\"task_types\\"] for open tasks, [\\"task_completed_at\\"] for completed tasks."},"page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Page number for pagination (e.g., 1, 2, 3)"},"per_page":{"type":"number","required":false,"visibility":"user-or-llm","description":"Results per page, max 100 (e.g., 25, 50, 100)"}}},"appconfig_create_application":{"id":"appconfig_create_application","name":"AppConfig Create Application","description":"Create an application in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the application to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the application"}}},"appconfig_create_configuration_profile":{"id":"appconfig_create_configuration_profile","name":"AppConfig Create Configuration Profile","description":"Create a configuration profile in an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the configuration profile in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the configuration profile"},"locationUri":{"type":"string","required":true,"visibility":"user-or-llm","description":"Where the configuration is stored. Use \\"hosted\\" for AppConfig-hosted configurations, or an SSM/S3 URI"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"ARN of an IAM role to retrieve the configuration (required for non-hosted URIs)"},"type":{"type":"string","required":false,"visibility":"user-or-llm","description":"Profile type: AWS.Freeform (default) or AWS.AppConfig.FeatureFlags"}}},"appconfig_create_environment":{"id":"appconfig_create_environment","name":"AppConfig Create Environment","description":"Create an environment for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to create the environment in"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the environment to create"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the environment"}}},"appconfig_create_hosted_configuration_version":{"id":"appconfig_create_hosted_configuration_version","name":"AppConfig Create Hosted Configuration Version","description":"Create a new hosted configuration version for an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to add the version to"},"content":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration content (e.g., a JSON or YAML document)"},"contentType":{"type":"string","required":true,"visibility":"user-or-llm","description":"Content type of the configuration (e.g., application/json, text/plain)"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the configuration version"},"latestVersionNumber":{"type":"number","required":false,"visibility":"user-or-llm","description":"The version number of the latest version, used for optimistic concurrency"},"versionLabel":{"type":"string","required":false,"visibility":"user-or-llm","description":"A user-defined label for the configuration version"}}},"appconfig_delete_application":{"id":"appconfig_delete_application","name":"AppConfig Delete Application","description":"Delete an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to delete"}}},"appconfig_delete_configuration_profile":{"id":"appconfig_delete_configuration_profile","name":"AppConfig Delete Configuration Profile","description":"Delete an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to delete"}}},"appconfig_delete_environment":{"id":"appconfig_delete_environment","name":"AppConfig Delete Environment","description":"Delete an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to delete"}}},"appconfig_delete_hosted_configuration_version":{"id":"appconfig_delete_hosted_configuration_version","name":"AppConfig Delete Hosted Configuration Version","description":"Delete a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID that owns the version"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to delete"}}},"appconfig_get_application":{"id":"appconfig_get_application","name":"AppConfig Get Application","description":"Get details about a single AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to retrieve"}}},"appconfig_get_configuration":{"id":"appconfig_get_configuration","name":"AppConfig Get Configuration","description":"Retrieve the latest deployed configuration for an AppConfig application, environment, and profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID or name to retrieve configuration for"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID or name to retrieve configuration for"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID or name to retrieve"}}},"appconfig_get_configuration_profile":{"id":"appconfig_get_configuration_profile","name":"AppConfig Get Configuration Profile","description":"Get details about a single AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to retrieve"}}},"appconfig_get_deployment":{"id":"appconfig_get_deployment","name":"AppConfig Get Deployment","description":"Get details about a specific AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment"}}},"appconfig_get_environment":{"id":"appconfig_get_environment","name":"AppConfig Get Environment","description":"Get details about a single AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to retrieve"}}},"appconfig_get_hosted_configuration_version":{"id":"appconfig_get_hosted_configuration_version","name":"AppConfig Get Hosted Configuration Version","description":"Retrieve a specific hosted configuration version from an AppConfig profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to read the version from"},"versionNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The version number to retrieve"}}},"appconfig_list_applications":{"id":"appconfig_list_applications","name":"AppConfig List Applications","description":"List applications in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of applications to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_configuration_profiles":{"id":"appconfig_list_configuration_profiles","name":"AppConfig List Configuration Profiles","description":"List configuration profiles for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profiles"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of configuration profiles to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployment_strategies":{"id":"appconfig_list_deployment_strategies","name":"AppConfig List Deployment Strategies","description":"List deployment strategies available in AWS AppConfig","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployment strategies to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_deployments":{"id":"appconfig_list_deployments","name":"AppConfig List Deployments","description":"List deployments for an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployments"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of deployments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_environments":{"id":"appconfig_list_environments","name":"AppConfig List Environments","description":"List environments for an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environments"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of environments to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_list_hosted_configuration_versions":{"id":"appconfig_list_hosted_configuration_versions","name":"AppConfig List Hosted Configuration Versions","description":"List hosted configuration versions for an AWS AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to list versions for"},"maxResults":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of versions to return (1-50)"},"nextToken":{"type":"string","required":false,"visibility":"user-or-llm","description":"Pagination token from a previous response"}}},"appconfig_start_deployment":{"id":"appconfig_start_deployment","name":"AppConfig Start Deployment","description":"Start deploying a configuration version to an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to deploy in"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to deploy to"},"deploymentStrategyId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The deployment strategy ID to use"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to deploy"},"configurationVersion":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration version to deploy"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"Description of the deployment"}}},"appconfig_stop_deployment":{"id":"appconfig_stop_deployment","name":"AppConfig Stop Deployment","description":"Stop an in-progress AWS AppConfig deployment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID of the deployment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID of the deployment"},"deploymentNumber":{"type":"number","required":true,"visibility":"user-or-llm","description":"The sequence number of the deployment to stop"}}},"appconfig_update_application":{"id":"appconfig_update_application","name":"AppConfig Update Application","description":"Update the name or description of an AWS AppConfig application","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the application"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the application"}}},"appconfig_update_configuration_profile":{"id":"appconfig_update_configuration_profile","name":"AppConfig Update Configuration Profile","description":"Update the name, description, or retrieval role of an AppConfig configuration profile","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the configuration profile"},"configurationProfileId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The configuration profile ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the configuration profile"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the configuration profile"},"retrievalRoleArn":{"type":"string","required":false,"visibility":"user-or-llm","description":"New ARN of the IAM role used to retrieve the configuration"}}},"appconfig_update_environment":{"id":"appconfig_update_environment","name":"AppConfig Update Environment","description":"Update the name or description of an AWS AppConfig environment","version":"1.0","params":{"region":{"type":"string","required":true,"visibility":"user-only","description":"AWS region (e.g., us-east-1)"},"accessKeyId":{"type":"string","required":true,"visibility":"user-only","description":"AWS access key ID"},"secretAccessKey":{"type":"string","required":true,"visibility":"user-only","description":"AWS secret access key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The application ID that owns the environment"},"environmentId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The environment ID to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"New name for the environment"},"description":{"type":"string","required":false,"visibility":"user-or-llm","description":"New description for the environment"}}},"arxiv_get_author_papers":{"id":"arxiv_get_author_papers","name":"ArXiv Get Author Papers","description":"Search for papers by a specific author on ArXiv.","version":"1.0.0","params":{"authorName":{"type":"string","required":true,"visibility":"user-or-llm","description":"Author name to search for"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"}}},"arxiv_get_paper":{"id":"arxiv_get_paper","name":"ArXiv Get Paper","description":"Get detailed information about a specific ArXiv paper by its ID.","version":"1.0.0","params":{"paperId":{"type":"string","required":true,"visibility":"user-or-llm","description":"ArXiv paper ID (e.g., \\"1706.03762\\")"}}},"arxiv_search":{"id":"arxiv_search","name":"ArXiv Search","description":"Search for academic papers on ArXiv by keywords, authors, titles, or other fields.","version":"1.0.0","params":{"searchQuery":{"type":"string","required":true,"visibility":"user-or-llm","description":"The search query to execute"},"searchField":{"type":"string","required":false,"visibility":"user-only","description":"Field to search in: all, ti (title), au (author), abs (abstract), co (comment), jr (journal), cat (category), rn (report number)"},"maxResults":{"type":"number","required":false,"visibility":"user-only","description":"Maximum number of results to return (default: 10, max: 2000)"},"sortBy":{"type":"string","required":false,"visibility":"user-only","description":"Sort by: relevance, lastUpdatedDate, submittedDate (default: relevance)"},"sortOrder":{"type":"string","required":false,"visibility":"user-only","description":"Sort order: ascending, descending (default: descending)"}}},"asana_add_comment":{"id":"asana_add_comment","name":"Asana Add Comment","description":"Add a comment (story) to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string)"},"text":{"type":"string","required":true,"visibility":"user-or-llm","description":"The text content of the comment"}},"oauth":{"required":true,"provider":"asana"}},"asana_add_followers":{"id":"asana_add_followers","name":"Asana Add Followers","description":"Add one or more followers to an Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task (numeric string)"},"followers":{"type":"array","required":true,"visibility":"user-or-llm","description":"Array of user GIDs to add as followers to the task"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_project":{"id":"asana_create_project","name":"Asana Create Project","description":"Create a new project in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the project will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the project"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the project"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_section":{"id":"asana_create_section","name":"Asana Create Section","description":"Create a new section in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to add the section to"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the section"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_subtask":{"id":"asana_create_subtask","name":"Asana Create Subtask","description":"Create a subtask under an existing Asana task","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the parent Asana task (numeric string)"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the subtask"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the subtask"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the subtask to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_create_task":{"id":"asana_create_task","name":"Asana Create Task","description":"Create a new task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) where the task will be created"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"Name of the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"User GID to assign the task to"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"asana_delete_task":{"id":"asana_delete_task","name":"Asana Delete Task","description":"Delete an Asana task by its GID (moves it to the trash)","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana task to delete (numeric string)"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_project":{"id":"asana_get_project","name":"Asana Get Project","description":"Retrieve a single Asana project by its GID","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to retrieve"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_projects":{"id":"asana_get_projects","name":"Asana Get Projects","description":"Retrieve all projects from an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to retrieve projects from"}},"oauth":{"required":true,"provider":"asana"}},"asana_get_task":{"id":"asana_get_task","name":"Asana Get Task","description":"Retrieve a single task by GID or get multiple tasks with filters","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":false,"visibility":"user-or-llm","description":"The globally unique identifier (GID) of the task. If not provided, will get multiple tasks."},"workspace":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to filter tasks (required when not using taskGid)"},"project":{"type":"string","required":false,"visibility":"user-or-llm","description":"Asana project GID (numeric string) to filter tasks"},"limit":{"type":"number","required":false,"visibility":"user-or-llm","description":"Maximum number of tasks to return (default: 50)"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_sections":{"id":"asana_list_sections","name":"Asana List Sections","description":"List all sections in an Asana project","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"projectGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"GID of the Asana project (numeric string) to list sections from"}},"oauth":{"required":true,"provider":"asana"}},"asana_list_workspaces":{"id":"asana_list_workspaces","name":"Asana List Workspaces","description":"List all Asana workspaces and organizations the authenticated user belongs to","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"}},"oauth":{"required":true,"provider":"asana"}},"asana_search_tasks":{"id":"asana_search_tasks","name":"Asana Search Tasks","description":"Search for tasks in an Asana workspace","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"workspace":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana workspace GID (numeric string) to search tasks in"},"text":{"type":"string","required":false,"visibility":"user-or-llm","description":"Text to search for in task names"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Filter tasks by assignee user GID"},"projects":{"type":"array","required":false,"visibility":"user-or-llm","description":"Array of Asana project GIDs (numeric strings) to filter tasks by"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Filter by completion status"}},"oauth":{"required":true,"provider":"asana"}},"asana_update_task":{"id":"asana_update_task","name":"Asana Update Task","description":"Update an existing task in Asana","version":"1.0.0","params":{"accessToken":{"type":"string","required":true,"visibility":"hidden","description":"OAuth access token for Asana"},"taskGid":{"type":"string","required":true,"visibility":"user-or-llm","description":"Asana task GID (numeric string) of the task to update"},"name":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated name for the task"},"notes":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated notes or description for the task"},"assignee":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated assignee user GID"},"completed":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Mark task as completed or not completed"},"due_on":{"type":"string","required":false,"visibility":"user-or-llm","description":"Updated due date in YYYY-MM-DD format"}},"oauth":{"required":true,"provider":"asana"}},"ashby_add_candidate_tag":{"id":"ashby_add_candidate_tag","name":"Ashby Add Candidate Tag","description":"Adds a tag to a candidate in Ashby and returns the updated candidate.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the tag to"},"tagId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the tag to add"}}},"ashby_anonymize_candidate":{"id":"ashby_anonymize_candidate","name":"Ashby Anonymize Candidate","description":"Strips personally identifiable information from a candidate in Ashby. This does not delete the candidate - the record and its applications remain, with the PII removed. Ashby exposes no candidate deletion endpoint; true deletion is UI-only, restricted by role, and limited to a 10-day window. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the candidate to anonymize"}}},"ashby_change_application_source":{"id":"ashby_change_application_source","name":"Ashby Change Application Source","description":"Changes the source attributed to an existing application, so programmatically created applications report correctly on the recruiting side. Requires the candidatesWrite permission.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"UUID of the application whose source should change"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the application to, as returned by List Sources. Omit only when unsetSource is true."},"unsetSource":{"type":"boolean","required":false,"visibility":"user-or-llm","description":"Set true to deliberately clear the application source. Required to unset, so that a missing or empty sourceId cannot wipe attribution by accident."}}},"ashby_change_application_stage":{"id":"ashby_change_application_stage","name":"Ashby Change Application Stage","description":"Moves an application to a different interview stage. Requires an archive reason when moving to an Archived stage.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"applicationId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the application to update the stage of"},"interviewStageId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the interview stage to move the application to"},"archiveReasonId":{"type":"string","required":false,"visibility":"user-or-llm","description":"Archive reason UUID. Required when moving to an Archived stage, ignored otherwise"}}},"ashby_create_application":{"id":"ashby_create_application","name":"Ashby Create Application","description":"Creates a new application for a candidate on a job. Optionally specify interview plan, stage, source, and credited user.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to consider for the job"},"jobId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the job to consider the candidate for"},"interviewPlanId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview plan to use (defaults to the job default plan)"},"interviewStageId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the interview stage to place the application in (defaults to first Lead stage)"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to set on the application"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the user the application is credited to"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"ISO 8601 timestamp to set as the application creation date (defaults to now)"}}},"ashby_create_candidate":{"id":"ashby_create_candidate","name":"Ashby Create Candidate","description":"Creates a new candidate record in Ashby.","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"name":{"type":"string","required":true,"visibility":"user-or-llm","description":"The candidate full name"},"email":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary email address for the candidate"},"phoneNumber":{"type":"string","required":false,"visibility":"user-or-llm","description":"Primary phone number for the candidate"},"linkedInUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"LinkedIn profile URL"},"githubUrl":{"type":"string","required":false,"visibility":"user-or-llm","description":"GitHub profile URL"},"website":{"type":"string","required":false,"visibility":"user-or-llm","description":"Personal website URL"},"sourceId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the source to attribute the candidate to"},"creditedToUserId":{"type":"string","required":false,"visibility":"user-or-llm","description":"UUID of the Ashby user to credit with sourcing this candidate"},"createdAt":{"type":"string","required":false,"visibility":"user-or-llm","description":"Backdated creation timestamp in ISO 8601 (e.g. 2024-01-01T00:00:00Z). Defaults to now."},"alternateEmailAddresses":{"type":"json","required":false,"visibility":"user-or-llm","description":"Array of additional email address strings to add to the candidate, e.g. [\\"a@x.com\\",\\"b@y.com\\"]"}}},"ashby_create_note":{"id":"ashby_create_note","name":"Ashby Create Note","description":"Creates a note on a candidate in Ashby. Supports plain text and HTML content (bold, italic, underline, links, lists, code).","version":"1.0.0","params":{"apiKey":{"type":"string","required":true,"visibility":"user-only","description":"Ashby API Key"},"candidateId":{"type":"string","required":true,"visibility":"user-or-llm","description":"The UUID of the candidate to add the note to"},"note":{"type":"string","required":true,"visibility":"user-or-llm","description":"The note content. If noteType is text/html, supports: , , , ,