diff --git a/apps/sim/app/api/copilot/api-keys/generate/route.ts b/apps/sim/app/api/copilot/api-keys/generate/route.ts index 5147de13d7d..dab9626c047 100644 --- a/apps/sim/app/api/copilot/api-keys/generate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/generate/route.ts @@ -1,11 +1,17 @@ +import { db } from '@sim/db' +import { apiKey as apiKeyTable } from '@sim/db/schema' +import { generateShortId } from '@sim/utils/id' import { type NextRequest, NextResponse } from 'next/server' import { generateCopilotApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' +import { createApiKey } from '@/lib/api-key/auth' +import { hashApiKey } from '@/lib/api-key/crypto' import { getSession } from '@/lib/auth' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { env } from '@/lib/core/config/env' +import { isHosted } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const POST = withRouteHandler(async (req: NextRequest) => { @@ -22,6 +28,33 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const { name } = parsed.data.body + // Self-hosted: create the key locally as a personal API key. The + // Mothership service does not trust self-hosted callers, so the + // remote generation flow always fails. Local keys work everywhere + // (X-API-Key on /api/mcp/copilot, /api/v1/workflows/{id}/execute, etc.). + if (!isHosted) { + const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!encryptedKey) { + return NextResponse.json({ error: 'Failed to encrypt API key' }, { status: 500 }) + } + const id = generateShortId() + await db.insert(apiKeyTable).values({ + id, + userId, + workspaceId: null, + name, + key: encryptedKey, + keyHash: hashApiKey(plainKey), + type: 'personal', + createdAt: new Date(), + updatedAt: new Date(), + }) + return NextResponse.json( + { success: true, key: { id, apiKey: plainKey } }, + { status: 201 } + ) + } + const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/generate`, { method: 'POST', headers: { diff --git a/apps/sim/app/api/copilot/api-keys/route.ts b/apps/sim/app/api/copilot/api-keys/route.ts index 8b2d7bf55bf..0b2617120a6 100644 --- a/apps/sim/app/api/copilot/api-keys/route.ts +++ b/apps/sim/app/api/copilot/api-keys/route.ts @@ -1,3 +1,6 @@ +import { db } from '@sim/db' +import { apiKey as apiKeyTable } from '@sim/db/schema' +import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts' import { getSession } from '@/lib/auth' @@ -5,6 +8,7 @@ import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { env } from '@/lib/core/config/env' +import { isHosted } from '@/lib/core/config/feature-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const GET = withRouteHandler(async (request: NextRequest) => { @@ -16,6 +20,33 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const userId = session.user.id + // Self-hosted: list user's personal keys directly from the api_key + // table. These are the same keys the generate endpoint creates and + // are valid for X-API-Key auth on /api/mcp/copilot (via the + // local-first lookup in route.ts). + if (!isHosted) { + const rows = await db + .select({ + id: apiKeyTable.id, + name: apiKeyTable.name, + keyHash: apiKeyTable.keyHash, + createdAt: apiKeyTable.createdAt, + lastUsed: apiKeyTable.lastUsed, + }) + .from(apiKeyTable) + .where(and(eq(apiKeyTable.userId, userId), eq(apiKeyTable.type, 'personal'))) + .orderBy(desc(apiKeyTable.createdAt)) + const keys = rows.map((k) => ({ + id: k.id, + // Last 6 of keyHash for display (we don't decrypt the stored key here) + displayKey: `•••••${(k.keyHash || '').slice(-6)}`, + name: k.name, + createdAt: k.createdAt ? k.createdAt.toISOString() : null, + lastUsed: k.lastUsed ? k.lastUsed.toISOString() : null, + })) + return NextResponse.json({ keys }, { status: 200 }) + } + const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/get-api-keys`, { method: 'POST', headers: { @@ -75,6 +106,19 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => { } const { id } = queryResult.data + // Self-hosted: delete the row directly. Scoped to the requesting user + // so callers can only delete their own keys. + if (!isHosted) { + const deleted = await db + .delete(apiKeyTable) + .where(and(eq(apiKeyTable.id, id), eq(apiKeyTable.userId, userId))) + .returning({ id: apiKeyTable.id }) + if (deleted.length === 0) { + return NextResponse.json({ error: 'Key not found' }, { status: 404 }) + } + return NextResponse.json({ success: true }, { status: 200 }) + } + const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/delete`, { method: 'POST', headers: { diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index 021b04aac04..75c48d8c4cd 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -11,7 +11,7 @@ import { type RequestId, } from '@modelcontextprotocol/sdk/types.js' import { db } from '@sim/db' -import { userStats } from '@sim/db/schema' +import { apiKey as apiKeyTable, userStats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -22,7 +22,9 @@ import { mcpRequestBodySchema, mcpToolCallParamsSchema } from '@/lib/api/contrac import { validateOAuthAccessToken } from '@/lib/auth/oauth-token' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { hashApiKey } from '@/lib/api-key/crypto' import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants' +import { isHosted } from '@/lib/core/config/feature-flags' import { createRequestId } from '@/lib/copilot/request/http' import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' import { orchestrateSubagentStream } from '@/lib/copilot/request/subagent' @@ -52,8 +54,33 @@ interface CopilotKeyAuthResult { /** * Validates a copilot API key by forwarding it to the Go copilot service's * `/api/validate-key` endpoint. Returns the associated userId on success. + * + * On self-hosted instances, also accepts local workspace/personal API keys + * (sk-sim-...) by looking them up directly in the api_key table — the + * Mothership service does not trust self-hosted INTERNAL_API_SECRETs, so + * remote validation always fails. The local lookup gives external MCP + * clients (claude.ai, etc.) a way to authenticate using the same + * workspace API key they use for /api/v1/workflows execution. */ async function authenticateCopilotApiKey(apiKey: string): Promise { + if (!isHosted) { + try { + const keyHash = hashApiKey(apiKey) + const [row] = await db + .select({ userId: apiKeyTable.userId, expiresAt: apiKeyTable.expiresAt }) + .from(apiKeyTable) + .where(eq(apiKeyTable.keyHash, keyHash)) + .limit(1) + if (row?.userId && (!row.expiresAt || row.expiresAt > new Date())) { + return { success: true, userId: row.userId } + } + } catch (error) { + logger.warn('Local Copilot API key lookup failed, falling through to remote validation', { + error: toError(error).message, + }) + } + } + try { const internalSecret = env.INTERNAL_API_SECRET if (!internalSecret) { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index 921175cee89..ab9fcccdff5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -142,14 +142,16 @@ export const allNavigationItems: NavigationItem[] = [ label: 'BYOK', icon: KeySquare, section: 'system', - requiresHosted: true, + // BYOK is functional on self-hosted via the byok.ts patch — surface in + // sidebar so users can manage workspace credentials from the UI. }, { id: 'copilot', label: 'Copilot Keys', icon: HexSimple, section: 'system', - requiresHosted: true, + // Copilot keys page works locally for managing keys (proxies to sim.ai + // for new key generation but listing/storing existing keys works). }, { id: 'inbox', diff --git a/apps/sim/blocks/utils.ts b/apps/sim/blocks/utils.ts index fd5c15bdc0a..2adb1892fd0 100644 --- a/apps/sim/blocks/utils.ts +++ b/apps/sim/blocks/utils.ts @@ -213,7 +213,11 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] { placeholder: 'Enter your API key', password: true, connectionDroppable: false, - required: true, + // On self-hosted, BYOK keys (workspace_byok_keys table) provide + // credentials at execution time — see lib/api-key/byok.ts. Per-block + // apiKey override stays available but is no longer required, so + // workflows authored without one don't fail serializer validation. + required: isHosted, condition: getApiKeyCondition(), }, { diff --git a/apps/sim/lib/api-key/byok.ts b/apps/sim/lib/api-key/byok.ts index a2aa198e859..df01b55eb71 100644 --- a/apps/sim/lib/api-key/byok.ts +++ b/apps/sim/lib/api-key/byok.ts @@ -114,6 +114,21 @@ export async function getApiKeyWithBYOK( const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId) + // On self-hosted, BYOK keys take precedence over a per-block apiKey for any + // supported provider — no hosted-model gate. Hosted environments retain the + // original behavior (BYOK only consulted for hosted-tier models). + if ( + !isHosted && + workspaceId && + (isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel) + ) { + const byokResult = await getBYOKKey(workspaceId, byokProviderId) + if (byokResult) { + logger.info('Using BYOK key (self-hosted)', { provider, model, workspaceId }) + return byokResult + } + } + if ( isHosted && workspaceId && diff --git a/apps/sim/lib/copilot/tools/mcp/definitions.ts b/apps/sim/lib/copilot/tools/mcp/definitions.ts index 5adc4471c6b..6ed596566b7 100644 --- a/apps/sim/lib/copilot/tools/mcp/definitions.ts +++ b/apps/sim/lib/copilot/tools/mcp/definitions.ts @@ -256,6 +256,542 @@ export const DIRECT_TOOL_DEFS: DirectToolDef[] = [ }, annotations: { destructiveHint: false }, }, + + // === Workflow read/inspect === + { + name: 'get_workflow_data', + toolId: 'get_workflow_data', + description: + "Get a workflow's full normalized state (blocks, edges, loops, parallels, variables) or a slice via data_type. Returns the same shape sim's internal copilot uses for inspection.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID to inspect.' }, + data_type: { + type: 'string', + description: + 'Optional slice: "all" (default), "blocks", "edges", "variables", "schedules", or "metadata".', + }, + }, + required: ['workflowId'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'get_block_outputs', + toolId: 'get_block_outputs', + description: + "Get the output schema for one or more blocks in a workflow. Useful for understanding what data downstream blocks can reference via .", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + blockIds: { + type: 'array', + items: { type: 'string' }, + description: 'Block IDs to inspect outputs for. Omit for all blocks.', + }, + }, + required: ['workflowId'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'get_block_upstream_references', + toolId: 'get_block_upstream_references', + description: + "Get the variable references ( and {{ENV_VAR}}) available to a given block based on its upstream connections. Use when wiring inputs to a new block.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + blockIds: { + type: 'array', + items: { type: 'string' }, + description: 'Block IDs to compute upstream references for.', + }, + }, + required: ['workflowId', 'blockIds'], + }, + annotations: { readOnlyHint: true }, + }, + + // === Workflow mutations === + { + name: 'delete_workflow', + toolId: 'delete_workflow', + description: + 'Permanently delete one or more workflows. Cascades to deployment versions, MCP tool registrations, and execution history.', + inputSchema: { + type: 'object', + properties: { + workflowIds: { + type: 'array', + items: { type: 'string' }, + description: 'Workflow IDs to delete.', + }, + }, + required: ['workflowIds'], + }, + annotations: { destructiveHint: true }, + }, + { + name: 'delete_folder', + toolId: 'delete_folder', + description: + 'Delete one or more empty folders. Workflows inside the folder must be moved or deleted first.', + inputSchema: { + type: 'object', + properties: { + folderIds: { + type: 'array', + items: { type: 'string' }, + description: 'Folder IDs to delete.', + }, + }, + required: ['folderIds'], + }, + annotations: { destructiveHint: true }, + }, + { + name: 'set_block_enabled', + toolId: 'set_block_enabled', + description: + 'Enable or disable a single block in a workflow. Disabled blocks are skipped during execution.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + blockId: { type: 'string', description: 'Block ID to toggle.' }, + enabled: { type: 'boolean', description: 'true to enable, false to disable.' }, + }, + required: ['workflowId', 'blockId', 'enabled'], + }, + annotations: { destructiveHint: false, idempotentHint: true }, + }, + { + name: 'set_global_workflow_variables', + toolId: 'set_global_workflow_variables', + description: + "Add, edit, or delete a workflow's global variables in one batched operation. Each operation specifies a name and an action.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + operations: { + type: 'array', + description: 'Variable mutations to apply.', + items: { + type: 'object', + properties: { + name: { type: 'string', description: 'Variable name.' }, + operation: { + type: 'string', + description: '"add", "edit", or "delete".', + }, + value: { description: 'Variable value (for add/edit).' }, + type: { + type: 'string', + description: 'Variable type: "string", "number", "boolean", "object", or "array".', + }, + }, + required: ['name', 'operation'], + }, + }, + }, + required: ['workflowId', 'operations'], + }, + annotations: { destructiveHint: false }, + }, + + // === Deployment === + // NOTE: run_workflow / run_workflow_until_block / run_from_block / run_block + // are intentionally NOT exposed via MCP. Their entries in + // tool-catalog-v1.ts are route: 'client' — sim's tool-executor only + // dispatches sim-routed tools to local handlers, so client-routed tools + // fail with "Built-in tool not found" when called via MCP. External + // callers should use the regular HTTP endpoint + // POST /api/workflows/{id}/execute for synchronous workflow execution. + { + name: 'deploy_api', + toolId: 'deploy_api', + description: + 'Deploy a workflow as an API endpoint, or undeploy it. Once deployed, the workflow is callable at /api/workflows/{id}/execute with a workspace API key.', + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + action: { + type: 'string', + description: '"deploy" (default) or "undeploy".', + }, + }, + required: ['workflowId'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'deploy_chat', + toolId: 'deploy_chat', + description: + "Deploy a workflow as a hosted chat UI, update its config, or undeploy it. Supports password/email/SSO/public auth modes and customizations.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + action: { type: 'string', description: '"deploy" (default), "update", or "undeploy".' }, + identifier: { type: 'string', description: 'Public chat slug (subdomain).' }, + title: { type: 'string', description: 'Display title for the chat UI.' }, + description: { type: 'string', description: 'Display description.' }, + welcomeMessage: { type: 'string', description: 'First message shown in the chat.' }, + authType: { + type: 'string', + description: '"public", "password", "email", or "sso".', + }, + password: { type: 'string', description: 'Required when authType="password".' }, + allowedEmails: { + type: 'array', + items: { type: 'string' }, + description: 'Required when authType="email".', + }, + subdomain: { type: 'string', description: 'Optional subdomain override.' }, + }, + required: ['workflowId'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'deploy_mcp', + toolId: 'deploy_mcp', + description: + "Publish a workflow as a tool on a workflow MCP server, or undeploy it. If serverId is omitted a new server is created. The tool's input schema is auto-generated from the workflow's start block input format.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID to publish as an MCP tool.' }, + action: { type: 'string', description: '"deploy" (default) or "undeploy".' }, + toolName: { type: 'string', description: 'Tool name as exposed to MCP clients.' }, + toolDescription: { type: 'string', description: 'Tool description for MCP clients.' }, + serverId: { + type: 'string', + description: 'Existing workflow MCP server to attach the tool to. Omit to create a new server.', + }, + }, + required: ['workflowId'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'redeploy', + toolId: 'redeploy', + description: + "Force a fresh deployment of a workflow's current draft state, replacing the active deployed version. Use after editing a deployed workflow to push changes live.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID to redeploy.' }, + }, + required: ['workflowId'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'check_deployment_status', + toolId: 'check_deployment_status', + description: + "Get the deployment status of a workflow across all surfaces: API endpoint, chat UI, and MCP tools. Includes whether a redeploy is needed because the draft has diverged.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID to check.' }, + }, + required: ['workflowId'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'get_deployment_version', + toolId: 'get_deployment_version', + description: + "Get a specific deployment version's snapshot for a workflow. Pass version=1 for the first deploy, etc. Useful for diffing versions or restoring an older deployment.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + version: { type: 'number', description: 'Deployment version number (1-indexed).' }, + }, + required: ['workflowId', 'version'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'revert_to_version', + toolId: 'revert_to_version', + description: + "Revert a workflow's draft state to a previous deployment version's snapshot. Does not change the active deployment — call redeploy after if you want to push the reverted state live.", + inputSchema: { + type: 'object', + properties: { + workflowId: { type: 'string', description: 'Workflow ID.' }, + version: { type: 'number', description: 'Deployment version number to revert to.' }, + }, + required: ['workflowId', 'version'], + }, + annotations: { destructiveHint: true }, + }, + + // === Workflow MCP server publishing === + { + name: 'list_workspace_mcp_servers', + toolId: 'list_workspace_mcp_servers', + description: + 'List all workflow MCP servers in a workspace, including each server\'s registered tools (workflow IDs and tool names). Pass workflowId to filter to servers that include a specific workflow.', + inputSchema: { + type: 'object', + properties: { + workspaceId: { type: 'string', description: 'Workspace ID. Defaults to current.' }, + workflowId: { type: 'string', description: 'Optional: filter to servers that include this workflow.' }, + }, + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'create_workspace_mcp_server', + toolId: 'create_workspace_mcp_server', + description: + "Create a new workflow MCP server in the workspace. Optionally seed it with one or more deployed workflows as tools.", + inputSchema: { + type: 'object', + properties: { + serverName: { type: 'string', description: 'Display name for the new server.' }, + description: { type: 'string', description: 'Optional server description.' }, + isPublic: { type: 'boolean', description: 'Whether the server is publicly accessible (default: false).' }, + workflowIds: { + type: 'array', + items: { type: 'string' }, + description: 'Optional initial workflow IDs to register as tools on this server.', + }, + }, + required: ['serverName'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'update_workspace_mcp_server', + toolId: 'update_workspace_mcp_server', + description: 'Rename a workflow MCP server, update its description, or toggle isPublic.', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string', description: 'Server ID to update.' }, + name: { type: 'string', description: 'New display name.' }, + description: { type: 'string', description: 'New description.' }, + isPublic: { type: 'boolean', description: 'Toggle public access.' }, + }, + required: ['serverId'], + }, + annotations: { destructiveHint: false, idempotentHint: true }, + }, + { + name: 'delete_workspace_mcp_server', + toolId: 'delete_workspace_mcp_server', + description: 'Permanently delete a workflow MCP server and unregister all its tools.', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string', description: 'Server ID to delete.' }, + }, + required: ['serverId'], + }, + annotations: { destructiveHint: true }, + }, + + // === Workspace assets (custom tools, MCP tools, skills, credentials) === + { + name: 'manage_custom_tool', + toolId: 'manage_custom_tool', + description: + 'Create, edit, delete, or list workspace custom JavaScript tools that agent blocks can call. Pass operation: "list", "get", "add", "edit", or "delete" plus operation-specific args.', + inputSchema: { + type: 'object', + properties: { + operation: { + type: 'string', + description: '"list", "get", "add", "edit", or "delete".', + }, + workspaceId: { type: 'string', description: 'Workspace ID. Defaults to current.' }, + toolId: { type: 'string', description: 'Tool ID (required for get/edit/delete).' }, + name: { type: 'string', description: 'Tool name (for add/edit).' }, + description: { type: 'string', description: 'Tool description (for add/edit).' }, + code: { type: 'string', description: 'JavaScript implementation (for add/edit).' }, + schema: { description: 'JSON schema for tool inputs (for add/edit).' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'manage_mcp_tool', + toolId: 'manage_mcp_tool', + description: + "Manage external MCP server connections used as tools by agent blocks. Pass operation: 'list', 'get', 'add', 'edit', or 'delete' plus operation-specific args (URL, auth, etc.).", + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string', description: '"list", "get", "add", "edit", or "delete".' }, + serverId: { type: 'string', description: 'Server ID (required for get/edit/delete).' }, + name: { type: 'string', description: 'Display name (for add/edit).' }, + url: { type: 'string', description: 'MCP server URL (for add/edit).' }, + transport: { type: 'string', description: 'Transport type, e.g. "streamable-http" (for add/edit).' }, + headers: { description: 'Optional auth headers as a JSON object.' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'manage_skill', + toolId: 'manage_skill', + description: + 'Manage workspace skills (reusable prompt+tool packages used by agent blocks). Pass operation: "list", "get", "add", "edit", or "delete".', + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string', description: '"list", "get", "add", "edit", or "delete".' }, + skillId: { type: 'string', description: 'Skill ID (required for get/edit/delete).' }, + name: { type: 'string', description: 'Skill name (for add/edit).' }, + description: { type: 'string', description: 'Skill description (for add/edit).' }, + prompt: { type: 'string', description: 'Skill prompt body (for add/edit).' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'manage_credential', + toolId: 'manage_credential', + description: + 'Manage OAuth/API credentials for third-party integrations (Slack, Google, GitHub, etc.). Pass operation: "list", "get", "add", "edit", or "delete".', + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string', description: '"list", "get", "add", "edit", or "delete".' }, + credentialId: { type: 'string', description: 'Credential ID (required for get/edit/delete).' }, + provider: { type: 'string', description: 'Provider name, e.g. "slack" (for add).' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + + // === OAuth === + { + name: 'oauth_get_auth_link', + toolId: 'oauth_get_auth_link', + description: + "Get an authorization URL for a third-party OAuth provider. The user opens this URL in their browser to grant access. Returns the URL only — does not initiate the flow.", + inputSchema: { + type: 'object', + properties: { + providerName: { type: 'string', description: 'Provider name (e.g. "slack", "google", "github").' }, + }, + required: ['providerName'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'oauth_request_access', + toolId: 'oauth_request_access', + description: + 'Notify the user they need to grant OAuth access for a provider. Returns a structured prompt the calling agent can show to guide the user.', + inputSchema: { + type: 'object', + properties: { + providerName: { type: 'string', description: 'Provider name (e.g. "slack", "google").' }, + }, + required: ['providerName'], + }, + annotations: { readOnlyHint: true }, + }, + + // === Jobs === + { + name: 'manage_job', + toolId: 'manage_job', + description: + 'List, get, pause, resume, update, or delete scheduled background jobs. Pass operation plus operation-specific args (jobId, etc.).', + inputSchema: { + type: 'object', + properties: { + operation: { type: 'string', description: '"list", "get", "pause", "resume", "update", or "delete".' }, + args: { description: 'Operation-specific arguments.' }, + }, + required: ['operation'], + }, + annotations: { destructiveHint: false }, + }, + { + name: 'complete_job', + toolId: 'complete_job', + description: + 'Mark a scheduled job complete (stops further executions). Use for until_complete-lifecycle jobs once their success condition has been met.', + inputSchema: { + type: 'object', + properties: { + jobId: { type: 'string', description: 'Job ID to mark complete.' }, + }, + required: ['jobId'], + }, + annotations: { destructiveHint: false, idempotentHint: true }, + }, + + // === Workspace VFS (file artifacts produced by workflows) === + { + name: 'read_file', + toolId: 'read', + description: + "Read a workspace file by path. Workspace files are produced by Function blocks (outputPath) and tools that emit artifacts. Returns text content.", + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'File path within the workspace, e.g. "files/result.json".' }, + }, + required: ['path'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'glob_files', + toolId: 'glob', + description: + 'List workspace files matching a glob pattern. Useful for finding artifacts produced by previous workflow runs.', + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Glob pattern, e.g. "files/**/*.json".' }, + }, + required: ['pattern'], + }, + annotations: { readOnlyHint: true }, + }, + { + name: 'grep_files', + toolId: 'grep', + description: + 'Search workspace file contents with a regex pattern. Returns matching lines with context.', + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string', description: 'Regex pattern to search for.' }, + output_mode: { + type: 'string', + description: '"content" (default), "files_with_matches", or "count".', + }, + }, + required: ['pattern'], + }, + annotations: { readOnlyHint: true }, + }, ] export const SUBAGENT_TOOL_DEFS: SubagentToolDef[] = [