From 0bb71f71020103c33d5c74cc2064e33608d2fab1 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 29 Dec 2025 15:00:12 -0800 Subject: [PATCH 1/9] Add api blcok as tool --- .../components/sub-block/components/tool-input/tool-input.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index cb1bfa38f5..45295f076a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -760,6 +760,7 @@ function CodeEditorSyncWrapper({ * in the tool selection dropdown. */ const BUILT_IN_TOOL_TYPES = new Set([ + 'api', 'file', 'function', 'knowledge', @@ -926,6 +927,7 @@ export function ToolInput({ const toolBlocks = getAllBlocks().filter( (block) => (block.category === 'tools' || + block.type === 'api' || block.type === 'workflow' || block.type === 'knowledge' || block.type === 'function') && From 173b3e62c8f2eb5d9b837b8d6fc48f0ca129e2f8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 29 Dec 2025 15:53:15 -0800 Subject: [PATCH 2/9] Add webhook block --- .../components/tool-input/tool-input.tsx | 2 + apps/sim/blocks/blocks/webhook_request.ts | 150 ++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + 3 files changed, 154 insertions(+) create mode 100644 apps/sim/blocks/blocks/webhook_request.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 45295f076a..e99a1eb04d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -773,6 +773,7 @@ const BUILT_IN_TOOL_TYPES = new Set([ 'tts', 'stt', 'memory', + 'webhook_request', 'workflow', ]) @@ -928,6 +929,7 @@ export function ToolInput({ (block) => (block.category === 'tools' || block.type === 'api' || + block.type === 'webhook_request' || block.type === 'workflow' || block.type === 'knowledge' || block.type === 'function') && diff --git a/apps/sim/blocks/blocks/webhook_request.ts b/apps/sim/blocks/blocks/webhook_request.ts new file mode 100644 index 0000000000..77acd41d27 --- /dev/null +++ b/apps/sim/blocks/blocks/webhook_request.ts @@ -0,0 +1,150 @@ +import { createHmac } from 'crypto' +import { createLogger } from '@sim/logger' +import { v4 as uuidv4 } from 'uuid' +import { WebhookIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import type { RequestResponse } from '@/tools/http/types' + +const logger = createLogger('WebhookRequestBlock') + +/** + * Generates HMAC-SHA256 signature for webhook payload + */ +function generateSignature(secret: string, timestamp: number, body: string): string { + const signatureBase = `${timestamp}.${body}` + return createHmac('sha256', secret).update(signatureBase).digest('hex') +} + +export const WebhookRequestBlock: BlockConfig = { + type: 'webhook_request', + name: 'Webhook', + description: 'Send a webhook request', + longDescription: + 'Send an HTTP POST request to a webhook URL with automatic webhook headers. Optionally sign the payload with HMAC-SHA256 for secure webhook delivery.', + docsLink: 'https://docs.sim.ai/blocks/webhook', + category: 'blocks', + bgColor: '#10B981', + icon: WebhookIcon, + subBlocks: [ + { + id: 'url', + title: 'Webhook URL', + type: 'short-input', + placeholder: 'https://example.com/webhook', + required: true, + }, + { + id: 'body', + title: 'Payload', + type: 'code', + placeholder: 'Enter JSON payload...', + language: 'json', + wandConfig: { + enabled: true, + maintainHistory: true, + prompt: `You are an expert JSON programmer. +Generate ONLY the raw JSON object based on the user's request. +The output MUST be a single, valid JSON object, starting with { and ending with }. + +Current payload: {context} + +Do not include any explanations, markdown formatting, or other text outside the JSON object. + +You have access to the following variables you can use to generate the JSON payload: +- Use angle brackets for workflow variables, e.g., ''. +- Use double curly braces for environment variables, e.g., '{{ENV_VAR_NAME}}'. + +Example: +{ + "event": "workflow.completed", + "data": { + "result": "", + "timestamp": "" + } +}`, + placeholder: 'Describe the webhook payload you need...', + generationType: 'json-object', + }, + }, + { + id: 'secret', + title: 'Signing Secret', + type: 'short-input', + placeholder: 'Optional: Secret for HMAC signature', + password: true, + connectionDroppable: false, + }, + { + id: 'headers', + title: 'Additional Headers', + type: 'table', + columns: ['Key', 'Value'], + description: 'Optional custom headers to include with the webhook request', + }, + ], + tools: { + access: ['http_request'], + config: { + tool: () => 'http_request', + params: (params: Record) => { + const timestamp = Date.now() + const deliveryId = uuidv4() + + // Start with webhook-specific headers + const webhookHeaders: Record = { + 'Content-Type': 'application/json', + 'X-Webhook-Timestamp': timestamp.toString(), + 'X-Delivery-ID': deliveryId, + 'Idempotency-Key': deliveryId, + } + + // Add signature if secret is provided + if (params.secret) { + const bodyString = + typeof params.body === 'string' ? params.body : JSON.stringify(params.body || {}) + const signature = generateSignature(params.secret, timestamp, bodyString) + webhookHeaders['X-Webhook-Signature'] = `t=${timestamp},v1=${signature}` + } + + // Merge with user-provided headers (user headers take precedence) + // Headers must be in TableRow format: { cells: { Key: string, Value: string } } + const userHeaders = params.headers || [] + const mergedHeaders = [ + ...Object.entries(webhookHeaders).map(([key, value]) => ({ + cells: { Key: key, Value: value }, + })), + ...userHeaders, + ] + + const payload = { + url: params.url, + method: 'POST', + headers: mergedHeaders, + body: params.body, + } + + logger.info('Sending webhook request', { + url: payload.url, + method: payload.method, + headers: mergedHeaders, + body: payload.body, + hasSignature: !!params.secret, + }) + + return payload + }, + }, + }, + inputs: { + url: { type: 'string', description: 'Webhook URL to send the request to' }, + body: { type: 'json', description: 'JSON payload to send' }, + secret: { type: 'string', description: 'Optional secret for HMAC-SHA256 signature' }, + headers: { type: 'json', description: 'Optional additional headers' }, + }, + outputs: { + data: { type: 'json', description: 'Response data from the webhook endpoint' }, + status: { type: 'number', description: 'HTTP status code' }, + headers: { type: 'json', description: 'Response headers' }, + }, +} + diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 43cb633924..8ccd46f262 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -131,6 +131,7 @@ import { WaitBlock } from '@/blocks/blocks/wait' import { WealthboxBlock } from '@/blocks/blocks/wealthbox' import { WebflowBlock } from '@/blocks/blocks/webflow' import { WebhookBlock } from '@/blocks/blocks/webhook' +import { WebhookRequestBlock } from '@/blocks/blocks/webhook_request' import { WhatsAppBlock } from '@/blocks/blocks/whatsapp' import { WikipediaBlock } from '@/blocks/blocks/wikipedia' import { WordPressBlock } from '@/blocks/blocks/wordpress' @@ -280,6 +281,7 @@ export const registry: Record = { wealthbox: WealthboxBlock, webflow: WebflowBlock, webhook: WebhookBlock, + webhook_request: WebhookRequestBlock, whatsapp: WhatsAppBlock, wikipedia: WikipediaBlock, wordpress: WordPressBlock, From 256520034b20b6b99eb5b11b596d88340856071f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 31 Dec 2025 11:40:32 -0800 Subject: [PATCH 3/9] Hitl v1 --- .../components/tag-dropdown/tag-dropdown.tsx | 41 ++++- apps/sim/blocks/blocks/human_in_the_loop.ts | 2 +- apps/sim/blocks/blocks/webhook_request.ts | 66 +------- apps/sim/executor/execution/block-executor.ts | 2 +- .../human-in-the-loop-handler.ts | 8 +- .../sim/lib/workflows/blocks/block-outputs.ts | 3 +- .../executor/human-in-the-loop-manager.ts | 8 +- apps/sim/tools/http/index.ts | 2 + apps/sim/tools/http/types.ts | 7 + apps/sim/tools/http/webhook_request.ts | 142 ++++++++++++++++++ apps/sim/tools/registry.ts | 3 +- 11 files changed, 206 insertions(+), 78 deletions(-) create mode 100644 apps/sim/tools/http/webhook_request.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 7521c1d2ed..6ac85a1672 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -754,6 +754,28 @@ export const TagDropdown: React.FC = ({ const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags } + } else if (sourceBlock.type === 'human_in_the_loop') { + const dynamicOutputs = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks) + + const isSelfReference = activeSourceBlockId === blockId + + if (dynamicOutputs.length > 0) { + const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`) + // For self-reference, only show url and resumeEndpoint (not response format fields) + blockTags = isSelfReference + ? allTags.filter( + (tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint') + ) + : allTags + } else { + const outputPaths = getBlockOutputPaths(sourceBlock.type, mergedSubBlocks) + const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) + blockTags = isSelfReference + ? allTags.filter( + (tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint') + ) + : allTags + } } else { const operationValue = mergedSubBlocks?.operation?.value ?? getSubBlockValue(activeSourceBlockId, 'operation') @@ -1073,7 +1095,24 @@ export const TagDropdown: React.FC = ({ blockTags = isSelfReference ? allTags.filter((tag) => tag.endsWith('.url')) : allTags } } else if (accessibleBlock.type === 'human_in_the_loop') { - blockTags = [`${normalizedBlockName}.url`] + const dynamicOutputs = getBlockOutputPaths(accessibleBlock.type, mergedSubBlocks) + + const isSelfReference = accessibleBlockId === blockId + + if (dynamicOutputs.length > 0) { + const allTags = dynamicOutputs.map((path) => `${normalizedBlockName}.${path}`) + // For self-reference, only show url and resumeEndpoint (not response format fields) + blockTags = isSelfReference + ? allTags.filter( + (tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint') + ) + : allTags + } else { + blockTags = [ + `${normalizedBlockName}.url`, + `${normalizedBlockName}.resumeEndpoint`, + ] + } } else { const operationValue = mergedSubBlocks?.operation?.value ?? getSubBlockValue(accessibleBlockId, 'operation') diff --git a/apps/sim/blocks/blocks/human_in_the_loop.ts b/apps/sim/blocks/blocks/human_in_the_loop.ts index d1a6eff6f5..8a69efb5fc 100644 --- a/apps/sim/blocks/blocks/human_in_the_loop.ts +++ b/apps/sim/blocks/blocks/human_in_the_loop.ts @@ -157,6 +157,6 @@ export const HumanInTheLoopBlock: BlockConfig = { }, outputs: { url: { type: 'string', description: 'Resume UI URL' }, - // apiUrl: { type: 'string', description: 'Resume API URL' }, // Commented out - not accessible as output + resumeEndpoint: { type: 'string', description: 'Resume API endpoint URL for direct curl requests' }, }, } diff --git a/apps/sim/blocks/blocks/webhook_request.ts b/apps/sim/blocks/blocks/webhook_request.ts index 77acd41d27..e7005bd8f7 100644 --- a/apps/sim/blocks/blocks/webhook_request.ts +++ b/apps/sim/blocks/blocks/webhook_request.ts @@ -1,20 +1,7 @@ -import { createHmac } from 'crypto' -import { createLogger } from '@sim/logger' -import { v4 as uuidv4 } from 'uuid' import { WebhookIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import type { RequestResponse } from '@/tools/http/types' -const logger = createLogger('WebhookRequestBlock') - -/** - * Generates HMAC-SHA256 signature for webhook payload - */ -function generateSignature(secret: string, timestamp: number, body: string): string { - const signatureBase = `${timestamp}.${body}` - return createHmac('sha256', secret).update(signatureBase).digest('hex') -} - export const WebhookRequestBlock: BlockConfig = { type: 'webhook_request', name: 'Webhook', @@ -83,57 +70,7 @@ Example: }, ], tools: { - access: ['http_request'], - config: { - tool: () => 'http_request', - params: (params: Record) => { - const timestamp = Date.now() - const deliveryId = uuidv4() - - // Start with webhook-specific headers - const webhookHeaders: Record = { - 'Content-Type': 'application/json', - 'X-Webhook-Timestamp': timestamp.toString(), - 'X-Delivery-ID': deliveryId, - 'Idempotency-Key': deliveryId, - } - - // Add signature if secret is provided - if (params.secret) { - const bodyString = - typeof params.body === 'string' ? params.body : JSON.stringify(params.body || {}) - const signature = generateSignature(params.secret, timestamp, bodyString) - webhookHeaders['X-Webhook-Signature'] = `t=${timestamp},v1=${signature}` - } - - // Merge with user-provided headers (user headers take precedence) - // Headers must be in TableRow format: { cells: { Key: string, Value: string } } - const userHeaders = params.headers || [] - const mergedHeaders = [ - ...Object.entries(webhookHeaders).map(([key, value]) => ({ - cells: { Key: key, Value: value }, - })), - ...userHeaders, - ] - - const payload = { - url: params.url, - method: 'POST', - headers: mergedHeaders, - body: params.body, - } - - logger.info('Sending webhook request', { - url: payload.url, - method: payload.method, - headers: mergedHeaders, - body: payload.body, - hasSignature: !!params.secret, - }) - - return payload - }, - }, + access: ['webhook_request'], }, inputs: { url: { type: 'string', description: 'Webhook URL to send the request to' }, @@ -147,4 +84,3 @@ Example: headers: { type: 'json', description: 'Response headers' }, }, } - diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 20beded805..9e9baf5130 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -510,7 +510,7 @@ export class BlockExecutor { const placeholderState: BlockState = { output: { url: resumeLinks.uiUrl, - // apiUrl: resumeLinks.apiUrl, // Hidden from output + resumeEndpoint: resumeLinks.apiUrl, }, executed: false, executionTime: existingState?.executionTime ?? 0, diff --git a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts index 23f90926d5..eea4c5d071 100644 --- a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts +++ b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts @@ -227,7 +227,7 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { if (resumeLinks) { output.url = resumeLinks.uiUrl - // output.apiUrl = resumeLinks.apiUrl // Hidden from output + output.resumeEndpoint = resumeLinks.apiUrl } return output @@ -576,9 +576,9 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { if (context.resumeLinks.uiUrl) { pauseOutput.url = context.resumeLinks.uiUrl } - // if (context.resumeLinks.apiUrl) { - // pauseOutput.apiUrl = context.resumeLinks.apiUrl - // } // Hidden from output + if (context.resumeLinks.apiUrl) { + pauseOutput.resumeEndpoint = context.resumeLinks.apiUrl + } } if (Array.isArray(context.inputFormat)) { diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index 309659d47d..b85e4491b3 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -226,9 +226,10 @@ export function getBlockOutputs( } if (blockType === 'human_in_the_loop') { - // For human_in_the_loop, only expose url (inputFormat fields are only available after resume) + // For human_in_the_loop, only expose url and resumeEndpoint (inputFormat fields are only available after resume) return { url: { type: 'string', description: 'Resume UI URL' }, + resumeEndpoint: { type: 'string', description: 'Resume API endpoint URL for direct curl requests' }, } } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index d05adfb64e..5c10de594b 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -538,15 +538,15 @@ export class PauseResumeManager { mergedOutput.resume = mergedOutput.resume ?? mergedResponse.resume - // Preserve url from resume links (apiUrl hidden from output) + // Preserve url and resumeEndpoint from resume links const resumeLinks = mergedOutput.resume ?? mergedResponse.resume if (resumeLinks && typeof resumeLinks === 'object') { if (resumeLinks.uiUrl) { mergedOutput.url = resumeLinks.uiUrl } - // if (resumeLinks.apiUrl) { - // mergedOutput.apiUrl = resumeLinks.apiUrl - // } // Hidden from output + if (resumeLinks.apiUrl) { + mergedOutput.resumeEndpoint = resumeLinks.apiUrl + } } for (const [key, value] of Object.entries(submissionPayload)) { diff --git a/apps/sim/tools/http/index.ts b/apps/sim/tools/http/index.ts index 7eb2081886..ae21e932de 100644 --- a/apps/sim/tools/http/index.ts +++ b/apps/sim/tools/http/index.ts @@ -1,3 +1,5 @@ import { requestTool } from './request' +import { webhookRequestTool } from './webhook_request' export const httpRequestTool = requestTool +export { webhookRequestTool } diff --git a/apps/sim/tools/http/types.ts b/apps/sim/tools/http/types.ts index aee763469a..9549a76e34 100644 --- a/apps/sim/tools/http/types.ts +++ b/apps/sim/tools/http/types.ts @@ -17,3 +17,10 @@ export interface RequestResponse extends ToolResponse { headers: Record } } + +export interface WebhookRequestParams { + url: string + body?: any + secret?: string + headers?: TableRow[] +} diff --git a/apps/sim/tools/http/webhook_request.ts b/apps/sim/tools/http/webhook_request.ts new file mode 100644 index 0000000000..710664be60 --- /dev/null +++ b/apps/sim/tools/http/webhook_request.ts @@ -0,0 +1,142 @@ +import { createHmac } from 'crypto' +import { v4 as uuidv4 } from 'uuid' +import type { ToolConfig } from '@/tools/types' +import type { RequestResponse, WebhookRequestParams } from './types' +import { transformTable } from './utils' + +/** + * Generates HMAC-SHA256 signature for webhook payload + */ +function generateSignature(secret: string, timestamp: number, body: string): string { + const signatureBase = `${timestamp}.${body}` + return createHmac('sha256', secret).update(signatureBase).digest('hex') +} + +export const webhookRequestTool: ToolConfig = { + id: 'webhook_request', + name: 'Webhook Request', + description: 'Send a webhook request with automatic headers and optional HMAC signing', + version: '1.0.0', + + params: { + url: { + type: 'string', + required: true, + description: 'The webhook URL to send the request to', + }, + body: { + type: 'object', + description: 'JSON payload to send', + }, + secret: { + type: 'string', + description: 'Optional secret for HMAC-SHA256 signature', + }, + headers: { + type: 'object', + description: 'Additional headers to include', + }, + }, + + request: { + url: (params: WebhookRequestParams) => params.url, + + method: () => 'POST', + + headers: (params: WebhookRequestParams) => { + const timestamp = Date.now() + const deliveryId = uuidv4() + + // Start with webhook-specific headers + const webhookHeaders: Record = { + 'Content-Type': 'application/json', + 'X-Webhook-Timestamp': timestamp.toString(), + 'X-Delivery-ID': deliveryId, + 'Idempotency-Key': deliveryId, + } + + // Add signature if secret is provided + if (params.secret) { + const bodyString = + typeof params.body === 'string' ? params.body : JSON.stringify(params.body || {}) + const signature = generateSignature(params.secret, timestamp, bodyString) + webhookHeaders['X-Webhook-Signature'] = `t=${timestamp},v1=${signature}` + } + + // Merge with user-provided headers (user headers take precedence) + // Handle different header formats: + // - Array of TableRow objects (from block usage): [{ cells: { Key, Value } }] + // - Plain object (from direct tool usage): { key: value } + // - undefined/null + let userHeaders: Record = {} + if (params.headers) { + if (Array.isArray(params.headers)) { + userHeaders = transformTable(params.headers) + } else if (typeof params.headers === 'object') { + userHeaders = params.headers as Record + } + } + + return { ...webhookHeaders, ...userHeaders } + }, + + body: (params: WebhookRequestParams) => params.body, + }, + + transformResponse: async (response: Response) => { + const contentType = response.headers.get('content-type') || '' + + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + + const data = await (contentType.includes('application/json') + ? response.json() + : response.text()) + + // Check if this is a proxy response + if ( + contentType.includes('application/json') && + typeof data === 'object' && + data !== null && + data.data !== undefined && + data.status !== undefined + ) { + return { + success: data.success, + output: { + data: data.data, + status: data.status, + headers: data.headers || {}, + }, + error: data.success ? undefined : data.error, + } + } + + return { + success: response.ok, + output: { + data, + status: response.status, + headers, + }, + error: undefined, + } + }, + + outputs: { + data: { + type: 'json', + description: 'Response data from the webhook endpoint', + }, + status: { + type: 'number', + description: 'HTTP status code', + }, + headers: { + type: 'object', + description: 'Response headers', + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 256710d261..e7e6dcb860 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -376,7 +376,7 @@ import { greptileStatusTool, } from '@/tools/greptile' import { guardrailsValidateTool } from '@/tools/guardrails' -import { httpRequestTool } from '@/tools/http' +import { httpRequestTool, webhookRequestTool } from '@/tools/http' import { hubspotCreateCompanyTool, hubspotCreateContactTool, @@ -1415,6 +1415,7 @@ export const tools: Record = { browser_use_run_task: browserUseRunTaskTool, openai_embeddings: openAIEmbeddingsTool, http_request: httpRequestTool, + webhook_request: webhookRequestTool, huggingface_chat: huggingfaceChatTool, llm_chat: llmChatTool, function_execute: functionExecuteTool, From bed3935b3afb36147177aad332d285cb710db707 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 31 Dec 2025 11:42:51 -0800 Subject: [PATCH 4/9] Cleanup --- apps/sim/tools/http/types.ts | 2 +- apps/sim/tools/http/webhook_request.ts | 14 +------------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/apps/sim/tools/http/types.ts b/apps/sim/tools/http/types.ts index 9549a76e34..2b7d307712 100644 --- a/apps/sim/tools/http/types.ts +++ b/apps/sim/tools/http/types.ts @@ -22,5 +22,5 @@ export interface WebhookRequestParams { url: string body?: any secret?: string - headers?: TableRow[] + headers?: Record } diff --git a/apps/sim/tools/http/webhook_request.ts b/apps/sim/tools/http/webhook_request.ts index 710664be60..b873a939b7 100644 --- a/apps/sim/tools/http/webhook_request.ts +++ b/apps/sim/tools/http/webhook_request.ts @@ -2,7 +2,6 @@ import { createHmac } from 'crypto' import { v4 as uuidv4 } from 'uuid' import type { ToolConfig } from '@/tools/types' import type { RequestResponse, WebhookRequestParams } from './types' -import { transformTable } from './utils' /** * Generates HMAC-SHA256 signature for webhook payload @@ -64,18 +63,7 @@ export const webhookRequestTool: ToolConfig = {} - if (params.headers) { - if (Array.isArray(params.headers)) { - userHeaders = transformTable(params.headers) - } else if (typeof params.headers === 'object') { - userHeaders = params.headers as Record - } - } + const userHeaders = params.headers || {} return { ...webhookHeaders, ...userHeaders } }, From 51a00b29ced8df52dc1c506c35bbb13e4b7a0c59 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 31 Dec 2025 12:04:57 -0800 Subject: [PATCH 5/9] Fix --- .../[workflowId]/[executionId]/[contextId]/route.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 9feef89bf3..78b090f790 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -21,12 +21,13 @@ export async function POST( ) { const { workflowId, executionId, contextId } = await params - const access = await validateWorkflowAccess(request, workflowId, false) + // Require deployed workflow and valid API key (same as webhook triggers) + const access = await validateWorkflowAccess(request, workflowId, true) if (access.error) { return NextResponse.json({ error: access.error.message }, { status: access.error.status }) } - const workflow = access.workflow! + const workflow = access.workflow let payload: any = {} try { @@ -148,7 +149,8 @@ export async function GET( ) { const { workflowId, executionId, contextId } = await params - const access = await validateWorkflowAccess(request, workflowId, false) + // Require deployed workflow and valid API key (same as webhook triggers) + const access = await validateWorkflowAccess(request, workflowId, true) if (access.error) { return NextResponse.json({ error: access.error.message }, { status: access.error.status }) } From b612e35aacaee625ea110b09c3beea4c3fa8b19e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 31 Dec 2025 12:35:45 -0800 Subject: [PATCH 6/9] Update names for fields in hitl --- apps/sim/blocks/blocks/human_in_the_loop.ts | 6 +++--- apps/sim/blocks/types.ts | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/sim/blocks/blocks/human_in_the_loop.ts b/apps/sim/blocks/blocks/human_in_the_loop.ts index 8a69efb5fc..736c3fef27 100644 --- a/apps/sim/blocks/blocks/human_in_the_loop.ts +++ b/apps/sim/blocks/blocks/human_in_the_loop.ts @@ -27,7 +27,7 @@ export const HumanInTheLoopBlock: BlockConfig = { // }, { id: 'builderData', - title: 'Paused Output', + title: 'Display Data', type: 'response-format', // condition: { field: 'operation', value: 'human' }, // Always shown since we only support human mode description: @@ -35,7 +35,7 @@ export const HumanInTheLoopBlock: BlockConfig = { }, { id: 'notification', - title: 'Notification', + title: 'Notification (Send URL)', type: 'tool-input', // condition: { field: 'operation', value: 'human' }, // Always shown since we only support human mode description: 'Configure notification tools to alert approvers (e.g., Slack, Email)', @@ -57,7 +57,7 @@ export const HumanInTheLoopBlock: BlockConfig = { // }, { id: 'inputFormat', - title: 'Resume Input', + title: 'Resume Form', type: 'input-format', // condition: { field: 'operation', value: 'human' }, // Always shown since we only support human mode description: 'Define the fields the approver can fill in when resuming', diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 572593f677..c2b3306dfa 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -217,6 +217,7 @@ export interface SubBlockConfig { hideFromPreview?: boolean // Hide this subblock from the workflow block preview requiresFeature?: string // Environment variable name that must be truthy for this subblock to be visible description?: string + tooltip?: string // Tooltip text displayed via info icon next to the title value?: (params: Record) => string grouped?: boolean scrollable?: boolean From 97882c86d5c320dd7acf012e43e96bfbe1c0ed26 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 31 Dec 2025 12:36:52 -0800 Subject: [PATCH 7/9] Fix hitl tag dropdown --- apps/sim/lib/workflows/blocks/block-outputs.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index b85e4491b3..5f1bdfa306 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -226,11 +226,24 @@ export function getBlockOutputs( } if (blockType === 'human_in_the_loop') { - // For human_in_the_loop, only expose url and resumeEndpoint (inputFormat fields are only available after resume) - return { + const hitlOutputs: Record = { url: { type: 'string', description: 'Resume UI URL' }, resumeEndpoint: { type: 'string', description: 'Resume API endpoint URL for direct curl requests' }, } + + const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) + + for (const field of normalizedInputFormat) { + const fieldName = field?.name?.trim() + if (!fieldName) continue + + hitlOutputs[fieldName] = { + type: (field?.type || 'any') as any, + description: `Field from resume form`, + } + } + + return hitlOutputs } if (blockType === 'approval') { From fb4f35e08f9d276f778f54f5cf587a853faa0350 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 31 Dec 2025 13:12:40 -0800 Subject: [PATCH 8/9] Update hitl dashboard --- .../[executionId]/[contextId]/route.ts | 8 +- .../[executionId]/resume-page-client.tsx | 1439 ++++++----------- 2 files changed, 486 insertions(+), 961 deletions(-) diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 78b090f790..2130511a97 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -21,8 +21,8 @@ export async function POST( ) { const { workflowId, executionId, contextId } = await params - // Require deployed workflow and valid API key (same as webhook triggers) - const access = await validateWorkflowAccess(request, workflowId, true) + // Allow resume from dashboard without requiring deployment + const access = await validateWorkflowAccess(request, workflowId, false) if (access.error) { return NextResponse.json({ error: access.error.message }, { status: access.error.status }) } @@ -149,8 +149,8 @@ export async function GET( ) { const { workflowId, executionId, contextId } = await params - // Require deployed workflow and valid API key (same as webhook triggers) - const access = await validateWorkflowAccess(request, workflowId, true) + // Allow access without API key for browser-based UI (same as parent execution endpoint) + const access = await validateWorkflowAccess(request, workflowId, false) if (access.error) { return NextResponse.json({ error: access.error.message }, { status: access.error.status }) } diff --git a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx index d96b890b3d..de188b7728 100644 --- a/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -1,10 +1,23 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' +import { RefreshCw } from 'lucide-react' import { useRouter } from 'next/navigation' -import { Badge, Button, Textarea } from '@/components/emcn' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' +import { + Badge, + Button, + Code, + Input, + Label, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, + Tooltip, +} from '@/components/emcn' import { Select, SelectContent, @@ -12,9 +25,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { Separator } from '@/components/ui/separator' import { useBrandConfig } from '@/lib/branding/branding' -import { cn } from '@/lib/core/utils/cn' import Nav from '@/app/(landing)/components/nav/nav' import type { ResumeStatus } from '@/executor/types' @@ -60,7 +71,8 @@ interface ResumeQueueEntrySummary { interface PausePointWithQueue { contextId: string - triggerBlockId: string + triggerBlockId?: string + blockId?: string response: any registeredAt: string resumeStatus: ResumeStatus @@ -105,14 +117,12 @@ interface ResumeExecutionPageProps { initialContextId?: string | null } -const RESUME_STATUS_STYLES: Record = { - paused: 'border-[var(--c-F59E0B)]/30 bg-[var(--c-F59E0B)]/10 text-[var(--c-F59E0B)]', - queued: - 'border-[var(--brand-tertiary)]/30 bg-[var(--brand-tertiary)]/10 text-[var(--brand-tertiary)]', - resuming: - 'border-[var(--brand-primary)]/30 bg-[var(--brand-primary)]/10 text-[var(--brand-primary)]', - resumed: 'border-[var(--text-success)]/30 bg-[var(--text-success)]/10 text-[var(--text-success)]', - failed: 'border-[var(--text-error)]/30 bg-[var(--text-error)]/10 text-[var(--text-error)]', +const STATUS_BADGE_VARIANT: Record = { + paused: 'orange', + queued: 'blue', + resuming: 'blue', + resumed: 'green', + failed: 'red', } function formatDate(value: string | null): string { @@ -129,17 +139,31 @@ function getStatusLabel(status: string): string { return status.charAt(0).toUpperCase() + status.slice(1) } -function ResumeStatusBadge({ status }: { status: string }) { - const style = - RESUME_STATUS_STYLES[status] ?? - 'border-[var(--border)] bg-[var(--surface-2)] text-[var(--text-secondary)]' +function StatusBadge({ status }: { status: string }) { return ( - + {getStatusLabel(status)} ) } +function getBlockNameFromSnapshot( + executionSnapshot: { snapshot?: string } | null | undefined, + blockId: string | undefined +): string | null { + if (!executionSnapshot?.snapshot || !blockId) return null + try { + const parsed = JSON.parse(executionSnapshot.snapshot) + const workflowState = parsed?.workflow + if (!workflowState?.blocks || !Array.isArray(workflowState.blocks)) return null + // Blocks are stored as an array of serialized blocks with id and metadata.name + const block = workflowState.blocks.find((b: { id: string }) => b.id === blockId) + return block?.metadata?.name || null + } catch { + return null + } +} + export default function ResumeExecutionPage({ params, initialExecutionDetail, @@ -152,9 +176,6 @@ export default function ResumeExecutionPage({ const [executionDetail, setExecutionDetail] = useState( initialExecutionDetail ) - const totalPauses = executionDetail?.totalPauseCount ?? 0 - const resumedCount = executionDetail?.resumedCount ?? 0 - const pendingCount = Math.max(0, totalPauses - resumedCount) const pausePoints = executionDetail?.pausePoints ?? [] const defaultContextId = useMemo(() => { @@ -164,33 +185,14 @@ export default function ResumeExecutionPage({ pausePoints[0]?.contextId ) }, [initialContextId, pausePoints]) - const actionablePausePoints = useMemo( - () => pausePoints.filter((point) => point.resumeStatus === 'paused'), - [pausePoints] - ) - - const groupedPausePoints = useMemo(() => { - const activeStatuses = new Set(['paused', 'queued', 'resuming']) - const resolvedStatuses = new Set(['resumed', 'failed']) - return { - active: pausePoints.filter((point) => activeStatuses.has(point.resumeStatus)), - resolved: pausePoints.filter((point) => resolvedStatuses.has(point.resumeStatus)), - } - }, [pausePoints]) - - const [selectedContextId, setSelectedContextId] = useState( - defaultContextId ?? null - ) + const [selectedContextId, setSelectedContextId] = useState(defaultContextId ?? null) const [selectedDetail, setSelectedDetail] = useState(null) - const [selectedStatus, setSelectedStatus] = - useState('paused') + const [selectedStatus, setSelectedStatus] = useState('paused') const [queuePosition, setQueuePosition] = useState(undefined) const [resumeInputs, setResumeInputs] = useState>({}) const [resumeInput, setResumeInput] = useState('') - const [formValuesByContext, setFormValuesByContext] = useState< - Record> - >({}) + const [formValuesByContext, setFormValuesByContext] = useState>>({}) const [formValues, setFormValues] = useState>({}) const [formErrors, setFormErrors] = useState>({}) const [loadingDetail, setLoadingDetail] = useState(false) @@ -201,104 +203,62 @@ export default function ResumeExecutionPage({ const normalizeInputFormatFields = useCallback((raw: any): NormalizedInputField[] => { if (!Array.isArray(raw)) return [] - return raw .map((field: any, index: number) => { if (!field || typeof field !== 'object') return null - const name = typeof field.name === 'string' ? field.name.trim() : '' if (!name) return null - - const id = typeof field.id === 'string' && field.id.length > 0 ? field.id : `field_${index}` - const label = - typeof field.label === 'string' && field.label.trim().length > 0 - ? field.label.trim() - : name - const type = - typeof field.type === 'string' && field.type.trim().length > 0 ? field.type : 'string' - const description = - typeof field.description === 'string' && field.description.trim().length > 0 - ? field.description.trim() - : undefined - const placeholder = - typeof field.placeholder === 'string' && field.placeholder.trim().length > 0 - ? field.placeholder.trim() - : undefined - const required = field.required === true - const options = Array.isArray(field.options) ? field.options : undefined - const rows = typeof field.rows === 'number' ? field.rows : undefined - return { - id, + id: typeof field.id === 'string' && field.id.length > 0 ? field.id : `field_${index}`, name, - label, - type, - description, - placeholder, + label: typeof field.label === 'string' && field.label.trim().length > 0 ? field.label.trim() : name, + type: typeof field.type === 'string' && field.type.trim().length > 0 ? field.type : 'string', + description: typeof field.description === 'string' && field.description.trim().length > 0 ? field.description.trim() : undefined, + placeholder: typeof field.placeholder === 'string' && field.placeholder.trim().length > 0 ? field.placeholder.trim() : undefined, value: field.value, - required, - options, - rows, + required: field.required === true, + options: Array.isArray(field.options) ? field.options : undefined, + rows: typeof field.rows === 'number' ? field.rows : undefined, } as NormalizedInputField }) .filter((field): field is NormalizedInputField => field !== null) }, []) - const formatValueForInputField = useCallback( - (field: NormalizedInputField, value: any): string => { - if (value === undefined || value === null) { + const formatValueForInputField = useCallback((field: NormalizedInputField, value: any): string => { + if (value === undefined || value === null) return '' + switch (field.type) { + case 'boolean': + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase() + if (normalized === 'true' || normalized === 'false') return normalized + } return '' - } - - switch (field.type) { - case 'boolean': - if (typeof value === 'boolean') { - return value ? 'true' : 'false' - } - if (typeof value === 'string') { - const normalized = value.trim().toLowerCase() - if (normalized === 'true' || normalized === 'false') { - return normalized - } - } - return '' - case 'number': - if (typeof value === 'number') { - return Number.isFinite(value) ? String(value) : '' - } - if (typeof value === 'string') { - return value - } + case 'number': + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : '' + if (typeof value === 'string') return value + return '' + case 'array': + case 'object': + case 'files': + if (typeof value === 'string') return value + try { + return JSON.stringify(value, null, 2) + } catch { return '' - case 'array': - case 'object': - case 'files': - if (typeof value === 'string') { - return value - } - try { - return JSON.stringify(value, null, 2) - } catch { - return '' - } - default: - return typeof value === 'string' ? value : JSON.stringify(value) - } - }, - [] - ) + } + default: + return typeof value === 'string' ? value : JSON.stringify(value) + } + }, []) const buildInitialFormValues = useCallback( (fields: NormalizedInputField[], submission?: Record) => { const initial: Record = {} - for (const field of fields) { - const candidate = - submission && Object.hasOwn(submission, field.name) ? submission[field.name] : field.value - + const candidate = submission && Object.hasOwn(submission, field.name) ? submission[field.name] : field.value initial[field.name] = formatValueForInputField(field, candidate) } - return initial }, [formatValueForInputField] @@ -315,144 +275,137 @@ export default function ResumeExecutionPage({ } }, []) - const parseFormValue = useCallback( - (field: NormalizedInputField, rawValue: string): { value: any; error?: string } => { - const value = rawValue ?? '' - - switch (field.type) { - case 'number': { - if (!value.trim()) { - return { value: null } - } - const numericValue = Number(value) - if (Number.isNaN(numericValue)) { - return { value: null, error: 'Enter a valid number.' } - } - return { value: numericValue } - } - case 'boolean': { - if (value === 'true') return { value: true } - if (value === 'false') return { value: false } - if (!value) return { value: null } - return { value: null, error: 'Select true or false.' } + const parseFormValue = useCallback((field: NormalizedInputField, rawValue: string): { value: any; error?: string } => { + const value = rawValue ?? '' + switch (field.type) { + case 'number': { + if (!value.trim()) return { value: null } + const numericValue = Number(value) + if (Number.isNaN(numericValue)) return { value: null, error: 'Enter a valid number.' } + return { value: numericValue } + } + case 'boolean': { + if (value === 'true') return { value: true } + if (value === 'false') return { value: false } + if (!value) return { value: null } + return { value: null, error: 'Select true or false.' } + } + case 'array': + case 'object': + case 'files': { + if (!value.trim()) { + if (field.type === 'array') return { value: [] } + return { value: {} } } - case 'array': - case 'object': - case 'files': { - if (!value.trim()) { - if (field.type === 'array') return { value: [] } - return { value: {} } - } - try { - return { value: JSON.parse(value) } - } catch { - return { value: null, error: 'Enter valid JSON.' } - } + try { + return { value: JSON.parse(value) } + } catch { + return { value: null, error: 'Enter valid JSON.' } } - default: - return { value } } - }, - [] - ) - - const handleFormFieldChange = useCallback( - (fieldName: string, newValue: string) => { - if (!selectedContextId) return - - setFormValues((prev) => { - const updated = { ...prev, [fieldName]: newValue } - setFormValuesByContext((map) => ({ ...map, [selectedContextId]: updated })) - return updated - }) - - setFormErrors((prev) => { - if (!prev[fieldName]) { - return prev - } - const { [fieldName]: _, ...rest } = prev - return rest - }) - }, - [selectedContextId] - ) - - const renderFieldInput = useCallback( - (field: NormalizedInputField) => { - const value = formValues[field.name] ?? '' + default: + return { value } + } + }, []) - switch (field.type) { - case 'boolean': { - const selectValue = value === 'true' || value === 'false' ? value : '__unset__' - return ( - - ) - } - case 'number': - return ( - handleFormFieldChange(field.name, event.target.value)} - placeholder={field.placeholder ?? 'Enter a number...'} - /> - ) - case 'array': - case 'object': - case 'files': + const handleFormFieldChange = useCallback((fieldName: string, newValue: string) => { + if (!selectedContextId) return + setFormValues((prev) => { + const updated = { ...prev, [fieldName]: newValue } + setFormValuesByContext((map) => ({ ...map, [selectedContextId]: updated })) + return updated + }) + setFormErrors((prev) => { + if (!prev[fieldName]) return prev + const { [fieldName]: _, ...rest } = prev + return rest + }) + }, [selectedContextId]) + + const renderFieldInput = useCallback((field: NormalizedInputField) => { + const value = formValues[field.name] ?? '' + switch (field.type) { + case 'boolean': { + const selectValue = value === 'true' || value === 'false' ? value : '__unset__' + return ( + + ) + } + case 'number': + return ( + handleFormFieldChange(field.name, e.target.value)} + placeholder={field.placeholder ?? 'Enter a number...'} + /> + ) + case 'array': + case 'object': + case 'files': + return ( +