improvement(hitl): add webhook notification and resume, add webhook block (#2673)

* Add api blcok as tool

* Add webhook block

* Hitl v1

* Cleanup

* Fix

* Update names for fields in hitl

* Fix hitl tag dropdown

* Update hitl dashboard

* Lint
This commit is contained in:
Siddharth Ganesan
2026-01-06 13:58:44 -08:00
committed by GitHub
parent 155f544ce8
commit 8215a819e5
16 changed files with 873 additions and 700 deletions

View File

@@ -21,12 +21,13 @@ export async function POST(
) {
const { workflowId, executionId, contextId } = await params
// 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 })
}
const workflow = access.workflow!
const workflow = access.workflow
let payload: any = {}
try {
@@ -148,6 +149,7 @@ export async function GET(
) {
const { workflowId, executionId, contextId } = await params
// 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 })

View File

@@ -755,6 +755,24 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
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')
@@ -1074,7 +1092,19 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
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')

View File

@@ -760,6 +760,7 @@ function CodeEditorSyncWrapper({
* in the tool selection dropdown.
*/
const BUILT_IN_TOOL_TYPES = new Set([
'api',
'file',
'function',
'knowledge',
@@ -772,6 +773,7 @@ const BUILT_IN_TOOL_TYPES = new Set([
'tts',
'stt',
'memory',
'webhook_request',
'workflow',
])
@@ -926,6 +928,8 @@ export function ToolInput({
const toolBlocks = getAllBlocks().filter(
(block) =>
(block.category === 'tools' ||
block.type === 'api' ||
block.type === 'webhook_request' ||
block.type === 'workflow' ||
block.type === 'knowledge' ||
block.type === 'function') &&

View File

@@ -27,7 +27,7 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
// },
{
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<ResponseBlockOutput> = {
},
{
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<ResponseBlockOutput> = {
// },
{
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',
@@ -157,6 +157,9 @@ export const HumanInTheLoopBlock: BlockConfig<ResponseBlockOutput> = {
},
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',
},
},
}

View File

@@ -0,0 +1,86 @@
import { WebhookIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import type { RequestResponse } from '@/tools/http/types'
export const WebhookRequestBlock: BlockConfig<RequestResponse> = {
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., '<blockName.output>'.
- Use double curly braces for environment variables, e.g., '{{ENV_VAR_NAME}}'.
Example:
{
"event": "workflow.completed",
"data": {
"result": "<agent.content>",
"timestamp": "<function.result>"
}
}`,
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: ['webhook_request'],
},
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' },
},
}

View File

@@ -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<string, BlockConfig> = {
wealthbox: WealthboxBlock,
webflow: WebflowBlock,
webhook: WebhookBlock,
webhook_request: WebhookRequestBlock,
whatsapp: WhatsAppBlock,
wikipedia: WikipediaBlock,
wordpress: WordPressBlock,

View File

@@ -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, any>) => string
grouped?: boolean
scrollable?: boolean

View File

@@ -526,7 +526,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,

View File

@@ -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)) {

View File

@@ -226,10 +226,27 @@ export function getBlockOutputs(
}
if (blockType === 'human_in_the_loop') {
// For human_in_the_loop, only expose url (inputFormat fields are only available after resume)
return {
const hitlOutputs: Record<string, any> = {
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') {

View File

@@ -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)) {

View File

@@ -1,3 +1,5 @@
import { requestTool } from './request'
import { webhookRequestTool } from './webhook_request'
export const httpRequestTool = requestTool
export { webhookRequestTool }

View File

@@ -17,3 +17,10 @@ export interface RequestResponse extends ToolResponse {
headers: Record<string, string>
}
}
export interface WebhookRequestParams {
url: string
body?: any
secret?: string
headers?: Record<string, string>
}

View File

@@ -0,0 +1,130 @@
import { createHmac } from 'crypto'
import { v4 as uuidv4 } from 'uuid'
import type { ToolConfig } from '@/tools/types'
import type { RequestResponse, WebhookRequestParams } from './types'
/**
* 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<WebhookRequestParams, RequestResponse> = {
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<string, string> = {
'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)
const userHeaders = params.headers || {}
return { ...webhookHeaders, ...userHeaders }
},
body: (params: WebhookRequestParams) => params.body,
},
transformResponse: async (response: Response) => {
const contentType = response.headers.get('content-type') || ''
const headers: Record<string, string> = {}
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',
},
},
}

View File

@@ -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<string, ToolConfig> = {
browser_use_run_task: browserUseRunTaskTool,
openai_embeddings: openAIEmbeddingsTool,
http_request: httpRequestTool,
webhook_request: webhookRequestTool,
huggingface_chat: huggingfaceChatTool,
llm_chat: llmChatTool,
function_execute: functionExecuteTool,