+ {options?.sections?.map((section, index) => {
+ // Get the API key from the sections to use in curl command formatting
+ const apiKey = options.sections?.find((s) => s.label === 'API Key')?.content || ''
- {/* Copyable code block */}
-
- ))}
+ )
+ })}
{/* Status and Delete button row - with pulsing green indicator */}
diff --git a/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx b/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx
index c7126ad442..a3eefc651e 100644
--- a/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx
+++ b/sim/app/w/[id]/components/workflow-block/components/connection-blocks/connection-blocks.tsx
@@ -1,4 +1,5 @@
import { Card } from '@/components/ui/card'
+import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { ConnectedBlock, useBlockConnections } from '@/app/w/[id]/hooks/use-block-connections'
interface ConnectionBlocksProps {
@@ -69,6 +70,42 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP
}))
}
+ // Extract fields from starter block input format
+ const extractFieldsFromStarterInput = (connection: ConnectedBlock): ResponseField[] => {
+ // Only process for starter blocks
+ if (connection.type !== 'starter') return []
+
+ try {
+ // Get input format from subblock store
+ const inputFormat = useSubBlockStore.getState().getValue(connection.id, 'inputFormat')
+
+ // Make sure we have a valid input format
+ if (!inputFormat || !Array.isArray(inputFormat) || inputFormat.length === 0) {
+ return [{ name: 'input', type: 'any' }]
+ }
+
+ // Check if any fields have been configured with names
+ const hasConfiguredFields = inputFormat.some(
+ (field: any) => field.name && field.name.trim() !== ''
+ )
+
+ // If no fields have been configured, return the default input field
+ if (!hasConfiguredFields) {
+ return [{ name: 'input', type: 'any' }]
+ }
+
+ // Map input fields to response fields
+ return inputFormat.map((field: any) => ({
+ name: `input.${field.name}`,
+ type: field.type || 'string',
+ description: field.description,
+ }))
+ } catch (e) {
+ console.error('Error extracting fields from starter input format:', e)
+ return [{ name: 'input', type: 'any' }]
+ }
+ }
+
// Deduplicate connections by ID
const connectionMap = incomingConnections.reduce(
(acc, connection) => {
@@ -112,6 +149,20 @@ export function ConnectionBlocks({ blockId, setIsConnecting }: ConnectionBlocksP
return (
{sortedConnections.map((connection, index) => {
+ // Special handling for starter blocks with input format
+ if (connection.type === 'starter') {
+ const starterFields = extractFieldsFromStarterInput(connection)
+
+ if (starterFields.length > 0) {
+ return (
+
+ {starterFields.map((field) => renderConnectionCard(connection, field))}
+
+ )
+ }
+ }
+
+ // Regular connection handling
return (
{Array.isArray(connection.outputType)
diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx
new file mode 100644
index 0000000000..f3003ae6e2
--- /dev/null
+++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/starter/input-format.tsx
@@ -0,0 +1,205 @@
+import { ChevronDown, Plus, Trash } from 'lucide-react'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { cn } from '@/lib/utils'
+import { useSubBlockValue } from '../../hooks/use-sub-block-value'
+
+interface InputField {
+ id: string
+ name: string
+ type: 'string' | 'number' | 'boolean' | 'object' | 'array'
+ collapsed?: boolean
+}
+
+interface InputFormatProps {
+ blockId: string
+ subBlockId: string
+}
+
+// Default values
+const DEFAULT_FIELD: InputField = {
+ id: crypto.randomUUID(),
+ name: '',
+ type: 'string',
+ collapsed: true,
+}
+
+export function InputFormat({ blockId, subBlockId }: InputFormatProps) {
+ // State hooks
+ const [value, setValue] = useSubBlockValue
(blockId, subBlockId)
+ const fields = value || [DEFAULT_FIELD]
+
+ // Field operations
+ const addField = () => {
+ const newField: InputField = {
+ ...DEFAULT_FIELD,
+ id: crypto.randomUUID(),
+ }
+ setValue([...fields, newField])
+ }
+
+ const removeField = (id: string) => {
+ if (fields.length === 1) return
+ setValue(fields.filter((field) => field.id !== id))
+ }
+
+ // Update handlers
+ const updateField = (id: string, field: keyof InputField, value: any) => {
+ setValue(fields.map((f) => (f.id === id ? { ...f, [field]: value } : f)))
+ }
+
+ const toggleCollapse = (id: string) => {
+ setValue(fields.map((f) => (f.id === id ? { ...f, collapsed: !f.collapsed } : f)))
+ }
+
+ // Field header
+ const renderFieldHeader = (field: InputField, index: number) => {
+ const isUnconfigured = !field.name || field.name.trim() === ''
+
+ return (
+ toggleCollapse(field.id)}
+ >
+
+
+ {field.name ? field.name : `Field ${index + 1}`}
+
+ {field.name && (
+
+ {field.type}
+
+ )}
+
+
e.stopPropagation()}>
+
+
+
+
+
+ )
+ }
+
+ // Check if any fields have been configured
+ const hasConfiguredFields = fields.some((field) => field.name && field.name.trim() !== '')
+
+ // Main render
+ return (
+
+ {fields.map((field, index) => {
+ const isUnconfigured = !field.name || field.name.trim() === ''
+
+ return (
+
+ {renderFieldHeader(field, index)}
+
+ {!field.collapsed && (
+
+
+
+ updateField(field.id, 'name', e.target.value)}
+ placeholder="firstName"
+ className="h-9 placeholder:text-muted-foreground/50"
+ />
+
+
+
+
+
+
+
+
+
+ updateField(field.id, 'type', 'string')}
+ className="cursor-pointer"
+ >
+ Aa
+ String
+
+ updateField(field.id, 'type', 'number')}
+ className="cursor-pointer"
+ >
+ 123
+ Number
+
+ updateField(field.id, 'type', 'boolean')}
+ className="cursor-pointer"
+ >
+ 0/1
+ Boolean
+
+ updateField(field.id, 'type', 'object')}
+ className="cursor-pointer"
+ >
+ {'{}'}
+ Object
+
+ updateField(field.id, 'type', 'array')}
+ className="cursor-pointer"
+ >
+ []
+ Array
+
+
+
+
+
+ )}
+
+ )
+ })}
+
+ {!hasConfiguredFields && (
+
+ Define fields above to enable structured API input
+
+ )}
+
+ )
+}
diff --git a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx
index ea5921a23f..59809f8eeb 100644
--- a/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx
+++ b/sim/app/w/[id]/components/workflow-block/components/sub-block/components/webhook/components/providers/slack-config.tsx
@@ -28,7 +28,6 @@ export function SlackConfig({
return (
-
case 'folder-selector':
return
+ case 'input-format':
+ return
default:
- return null
+ return
Unknown input type: {config.type}
}
}
diff --git a/sim/blocks/blocks/starter.ts b/sim/blocks/blocks/starter.ts
index 8deda94c22..5ee0c0c86c 100644
--- a/sim/blocks/blocks/starter.ts
+++ b/sim/blocks/blocks/starter.ts
@@ -31,6 +31,14 @@ export const StarterBlock: BlockConfig
= {
],
value: () => 'manual',
},
+ // Structured Input format - visible if manual run is selected
+ {
+ id: 'inputFormat',
+ title: 'Input Format (for API calls)',
+ type: 'input-format',
+ layout: 'full',
+ condition: { field: 'startWorkflow', value: 'manual' },
+ },
// Webhook configuration
{
id: 'webhookProvider',
diff --git a/sim/blocks/types.ts b/sim/blocks/types.ts
index 412d2f44a8..754fc4d511 100644
--- a/sim/blocks/types.ts
+++ b/sim/blocks/types.ts
@@ -30,6 +30,7 @@ export type SubBlockType =
| 'schedule-config' // Schedule status and information
| 'file-selector' // File selector for Google Drive, etc.
| 'folder-selector' // Folder selector for Gmail, etc.
+ | 'input-format' // Input structure format
// Component width setting
export type SubBlockLayout = 'full' | 'half'
@@ -84,7 +85,10 @@ export interface SubBlockConfig {
title?: string
type: SubBlockType
layout?: SubBlockLayout
- options?: string[] | { label: string; id: string }[] | (() => string[] | { label: string; id: string }[])
+ options?:
+ | string[]
+ | { label: string; id: string }[]
+ | (() => string[] | { label: string; id: string }[])
min?: number
max?: number
columns?: string[]
diff --git a/sim/components/ui/tag-dropdown.tsx b/sim/components/ui/tag-dropdown.tsx
index ff67413828..9663e52696 100644
--- a/sim/components/ui/tag-dropdown.tsx
+++ b/sim/components/ui/tag-dropdown.tsx
@@ -115,9 +115,31 @@ export const TagDropdown: React.FC = ({
return prefix ? [prefix] : []
}
- // Special handling for starter block.
- // TODO: In the future, we will support response formats and required input types. For now, we just take the input altogether.
+ // Special handling for starter block with input format
if (isStarterBlock && prefix === 'response') {
+ try {
+ // Check if there's an input format defined
+ const inputFormatValue = useSubBlockStore
+ .getState()
+ .getValue(activeSourceBlockId || blockId, 'inputFormat')
+ if (inputFormatValue && Array.isArray(inputFormatValue) && inputFormatValue.length > 0) {
+ // Check if any fields have been configured with names
+ const hasConfiguredFields = inputFormatValue.some(
+ (field: any) => field.name && field.name.trim() !== ''
+ )
+
+ // If no fields have been configured, return the default input path
+ if (!hasConfiguredFields) {
+ return ['response.input']
+ }
+
+ // Return fields from input format
+ return inputFormatValue.map((field: any) => `response.input.${field.name}`)
+ }
+ } catch (e) {
+ logger.error('Error parsing input format:', { e })
+ }
+
return ['response.input']
}
diff --git a/sim/db/index.ts b/sim/db/index.ts
index 974984d81e..dec8e63c94 100644
--- a/sim/db/index.ts
+++ b/sim/db/index.ts
@@ -8,5 +8,9 @@ const connectionString = process.env.POSTGRES_URL || process.env.DATABASE_URL!
// Disable prefetch as it is not supported for "Transaction" pool mode
const client = postgres(connectionString, {
prepare: false,
+ idle_timeout: 30, // Keep connections alive for 30 seconds when idle
+ connect_timeout: 30, // Timeout after 30 seconds when connecting
})
+
+// Export the database client (never null)
export const db = drizzle(client)
diff --git a/sim/executor/index.ts b/sim/executor/index.ts
index 1c9096f8c8..e46253808c 100644
--- a/sim/executor/index.ts
+++ b/sim/executor/index.ts
@@ -39,8 +39,8 @@ export class Executor {
private workflow: SerializedWorkflow,
private initialBlockStates: Record = {},
private environmentVariables: Record = {},
- private workflowVariables: Record = {},
- workflowInput?: any
+ workflowInput?: any,
+ private workflowVariables: Record = {}
) {
this.validateWorkflow()
this.workflowInput = workflowInput || {}
@@ -334,18 +334,114 @@ export class Executor {
const starterBlock = this.workflow.blocks.find((block) => block.metadata?.id === 'starter')
if (starterBlock) {
// Initialize the starter block with the workflow input
- const starterOutput = {
- response: {
- input: this.workflowInput,
- },
+ try {
+ const blockParams = starterBlock.config.params
+ const inputFormat = blockParams?.inputFormat
+
+ // If input format is defined, structure the input according to the schema
+ if (inputFormat && Array.isArray(inputFormat) && inputFormat.length > 0) {
+ // Create structured input based on input format
+ const structuredInput: Record = {}
+
+ // Process each field in the input format
+ for (const field of inputFormat) {
+ if (field.name && field.type) {
+ // Get the field value from workflow input if available
+ const inputValue = this.workflowInput?.[field.name]
+
+ // Convert the value to the appropriate type
+ let typedValue = inputValue
+ if (inputValue !== undefined) {
+ if (field.type === 'number' && typeof inputValue !== 'number') {
+ typedValue = Number(inputValue)
+ } else if (field.type === 'boolean' && typeof inputValue !== 'boolean') {
+ typedValue = inputValue === 'true' || inputValue === true
+ } else if (
+ (field.type === 'object' || field.type === 'array') &&
+ typeof inputValue === 'string'
+ ) {
+ try {
+ typedValue = JSON.parse(inputValue)
+ } catch (e) {
+ logger.warn(`Failed to parse ${field.type} input for field ${field.name}:`, e)
+ }
+ }
+ }
+
+ // Add the field to structured input
+ structuredInput[field.name] = typedValue
+ }
+ }
+
+ // Initialize the starter block with structured input
+ const starterOutput = {
+ response: {
+ input: structuredInput,
+ ...structuredInput, // Add input fields directly at response level too
+ },
+ }
+
+ context.blockStates.set(starterBlock.id, {
+ output: starterOutput,
+ executed: true,
+ executionTime: 0,
+ })
+ } else {
+ // No input format defined or not an array,
+ // check if we're receiving input from API call
+ if (this.workflowInput && typeof this.workflowInput === 'object') {
+ // For API calls, use the raw input but make it accessible at both paths
+ const starterOutput = {
+ response: {
+ input: this.workflowInput,
+ ...this.workflowInput, // Make fields directly accessible at response level
+ },
+ }
+
+ logger.info(`Using API input type: ${typeof this.workflowInput}`, {
+ isArray: Array.isArray(this.workflowInput),
+ keys: Object.keys(this.workflowInput),
+ rawInput: JSON.stringify(this.workflowInput),
+ inputEmpty: Object.keys(this.workflowInput).length === 0,
+ })
+
+ context.blockStates.set(starterBlock.id, {
+ output: starterOutput,
+ executed: true,
+ executionTime: 0,
+ })
+ } else {
+ // Fallback for other cases
+ const starterOutput = {
+ response: {
+ input: this.workflowInput,
+ },
+ }
+
+ context.blockStates.set(starterBlock.id, {
+ output: starterOutput,
+ executed: true,
+ executionTime: 0,
+ })
+ }
+ }
+ } catch (e) {
+ logger.warn('Error processing starter block input format:', e)
+ // Fallback to raw input with both paths accessible
+ const starterOutput = {
+ response: {
+ input: this.workflowInput,
+ ...this.workflowInput, // Add input fields directly at response level too
+ },
+ }
+
+ context.blockStates.set(starterBlock.id, {
+ output: starterOutput,
+ executed: true,
+ executionTime: 0,
+ })
}
- context.blockStates.set(starterBlock.id, {
- output: starterOutput,
- executed: true,
- executionTime: 0,
- })
-
// Mark the starter block as executed and add its connections to the active path
context.executedBlocks.add(starterBlock.id)
diff --git a/sim/stores/panel/variables/store.ts b/sim/stores/panel/variables/store.ts
index 4b5db159af..e08636b558 100644
--- a/sim/stores/panel/variables/store.ts
+++ b/sim/stores/panel/variables/store.ts
@@ -107,7 +107,7 @@ export const useVariablesStore = create()(
if (!state.variables[id]) return state
// If name is being updated, ensure it's unique
- if (update.name) {
+ if (update.name !== undefined) {
const oldVariable = state.variables[id]
const oldVariableName = oldVariable.name
const workflowId = oldVariable.workflowId
@@ -124,7 +124,8 @@ export const useVariablesStore = create()(
nameIndex++
}
- // If name has changed, update references in subblocks
+ // Always update references in subblocks when name changes, even if empty
+ // This ensures references are updated even when name is completely cleared
if (uniqueName !== oldVariableName) {
// Update references in subblock store
const subBlockStore = useSubBlockStore.getState()
diff --git a/sim/stores/workflows/subblock/store.ts b/sim/stores/workflows/subblock/store.ts
index 6600326620..5eb8a4792a 100644
--- a/sim/stores/workflows/subblock/store.ts
+++ b/sim/stores/workflows/subblock/store.ts
@@ -4,6 +4,17 @@ import { SubBlockConfig } from '@/blocks/types'
import { loadSubblockValues, saveSubblockValues } from '../persistence'
import { useWorkflowRegistry } from '../registry/store'
+/**
+ * SubBlockState stores values for all subblocks in workflows
+ *
+ * Important implementation notes:
+ * 1. Values are stored per workflow, per block, per subblock
+ * 2. When workflows are synced to the database, the mergeSubblockState function
+ * in utils.ts combines the block structure with these values
+ * 3. If a subblock value exists here but not in the block structure
+ * (e.g., inputFormat in starter block), the merge function will include it
+ * in the synchronized state to ensure persistence
+ */
interface SubBlockState {
workflowValues: Record>> // Store values per workflow ID
}
diff --git a/sim/stores/workflows/sync.ts b/sim/stores/workflows/sync.ts
index b6989a31fd..9ef1d1dc8c 100644
--- a/sim/stores/workflows/sync.ts
+++ b/sim/stores/workflows/sync.ts
@@ -130,6 +130,24 @@ export async function fetchWorkflowsFromDB(): Promise {
})
})
+ // Get any additional subblock values that might not be in the state but are in the store
+ const storedValues = useSubBlockStore.getState().workflowValues[id] || {}
+ Object.entries(storedValues).forEach(([blockId, blockValues]) => {
+ if (!subblockValues[blockId]) {
+ subblockValues[blockId] = {}
+ }
+
+ Object.entries(blockValues).forEach(([subblockId, value]) => {
+ // Only update if not already set or if value is null
+ if (
+ subblockValues[blockId][subblockId] === null ||
+ subblockValues[blockId][subblockId] === undefined
+ ) {
+ subblockValues[blockId][subblockId] = value
+ }
+ })
+ })
+
// 4. Store the workflow state and subblock values in localStorage
// This ensures compatibility with existing code that loads from localStorage
localStorage.setItem(`workflow-${id}`, JSON.stringify(workflowState))
diff --git a/sim/stores/workflows/utils.ts b/sim/stores/workflows/utils.ts
index f00d481e36..2af9c49006 100644
--- a/sim/stores/workflows/utils.ts
+++ b/sim/stores/workflows/utils.ts
@@ -16,15 +16,24 @@ export function mergeSubblockState(
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
const subBlockStore = useSubBlockStore.getState()
+ // Get all the values stored in the subblock store for this workflow
+ const workflowSubblockValues = workflowId ? subBlockStore.workflowValues[workflowId] || {} : {}
+
return Object.entries(blocksToProcess).reduce(
(acc, [id, block]) => {
- // Skip if block is undefined or doesn't have subBlocks
- if (!block || !block.subBlocks) {
+ // Skip if block is undefined
+ if (!block) {
return acc
}
+ // Initialize subBlocks if not present
+ const blockSubBlocks = block.subBlocks || {}
+
+ // Get stored values for this block
+ const blockValues = workflowSubblockValues[id] || {}
+
// Create a deep copy of the block's subBlocks to maintain structure
- const mergedSubBlocks = Object.entries(block.subBlocks).reduce(
+ const mergedSubBlocks = Object.entries(blockSubBlocks).reduce(
(subAcc, [subBlockId, subBlock]) => {
// Skip if subBlock is undefined
if (!subBlock) {
@@ -37,9 +46,8 @@ export function mergeSubblockState(
// If workflowId is provided, use it to get the value
if (workflowId) {
// Try to get the value from the subblock store for this specific workflow
- const workflowValues = subBlockStore.workflowValues[workflowId]
- if (workflowValues && workflowValues[id]) {
- storedValue = workflowValues[id][subBlockId]
+ if (blockValues[subBlockId] !== undefined) {
+ storedValue = blockValues[subBlockId]
}
} else {
// Fall back to the active workflow if no workflowId is provided
@@ -57,6 +65,19 @@ export function mergeSubblockState(
{} as Record
)
+ // Add any values that exist in the store but aren't in the block structure
+ // This handles cases where block config has been updated but values still exist
+ Object.entries(blockValues).forEach(([subBlockId, value]) => {
+ if (!mergedSubBlocks[subBlockId] && value !== null && value !== undefined) {
+ // Create a minimal subblock structure
+ mergedSubBlocks[subBlockId] = {
+ id: subBlockId,
+ type: 'short-input', // Default type that's safe to use
+ value: value,
+ }
+ }
+ })
+
// Return the full block state with updated subBlocks
acc[id] = {
...block,