From 7b6aa728aed4d7f5b79ecfffcfe9ae458e1feae7 Mon Sep 17 00:00:00 2001
From: Octopus
Date: Wed, 6 May 2026 15:25:41 +0800
Subject: [PATCH 01/17] improvement(resolver): use context variables for block
outputs in function block code (#4223)
* v0.6.29: login improvements, posthog telemetry (#4026)
* feat(posthog): Add tracking on mothership abort (#4023)
Co-authored-by: Theodore Li
* fix(login): fix captcha headers for manual login (#4025)
* fix(signup): fix turnstile key loading
* fix(login): fix captcha header passing
* Catch user already exists, remove login form captcha
* fix: use context variables for block outputs in function block code
When a function block references another block's output via ,
the executor previously embedded the full value as a JavaScript literal
directly in the code string. For large outputs (>50 KB), this caused the code
string to exceed the terminal console display limit, making inputs appear
truncated or replaced with { __simTruncated: true } in the UI.
Instead, block output references in function block code are now stored as
named global variables (__blockRef_N) in the isolated VM context. The code
string only contains the compact variable name, keeping it small regardless
of the referenced value size.
Loop/parallel/env/workflow references are still inlined as literals since
the API route has no way to resolve them independently.
The _runtimeContextVars key is filtered from sanitizeInputsForLog so it
does not appear in execution logs or SSE events.
Pre-resolved context variables are merged with any variables produced by
the API route resolveCodeVariables, with executor values taking precedence.
Fixes #4195
* fix: address Cursor and Greptile bot review comments
- Pass preResolvedContextVariables through to shellEnvs for Shell language
(Cursor: shell loses pre-resolved block refs, executes against undefined vars)
- Remove duplicate CodeExecutionOutput interface declaration
(Cursor + Greptile: dead duplicate declaration in tools/function/types.ts)
- Deduplicate identical block references in resolveCodeWithContextVars so the
same reused multiple times shares one __blockRef_N slot
(Greptile P2: avoid duplicating large payloads across the wire)
* fix: shell block references and complex env value serialization
Two follow-ups to the function-block context-variable refactor:
- resolveCodeWithContextVars now emits `$__blockRef_N` for shell
function blocks so the script dereferences the env var injected
by the executor. Other languages still receive the bare identifier.
- The function-execute route now JSON-stringifies non-primitive
values when building shell env vars, replacing the previous
`String(v)` call that produced `[object Object]` for objects/arrays.
Co-Authored-By: Octopus
* fix lint
* review pass
* ignore shell comments
* update contract
* fix tests
---------
Co-authored-by: Waleed
Co-authored-by: Theodore Li
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti
Co-authored-by: octo-patch
Co-authored-by: Vikhyath Mondreti
---
apps/sim/app/api/function/execute/route.ts | 38 ++-
apps/sim/executor/execution/block-executor.ts | 19 +-
.../function/function-handler.test.ts | 28 +++
.../handlers/function/function-handler.ts | 5 +
.../executor/utils/reference-validation.ts | 6 +-
apps/sim/executor/variables/resolver.test.ts | 153 ++++++++++++
apps/sim/executor/variables/resolver.ts | 230 ++++++++++++++++++
apps/sim/lib/api/contracts/hotspots.ts | 1 +
apps/sim/tools/function/execute.test.ts | 21 ++
apps/sim/tools/function/execute.ts | 1 +
apps/sim/tools/function/types.ts | 2 +
bun.lock | 1 -
12 files changed, 494 insertions(+), 11 deletions(-)
create mode 100644 apps/sim/executor/variables/resolver.test.ts
diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts
index 89109b482ef..bd18d5686cd 100644
--- a/apps/sim/app/api/function/execute/route.ts
+++ b/apps/sim/app/api/function/execute/route.ts
@@ -587,6 +587,26 @@ function cleanStdout(stdout: string): string {
return stdout
}
+/**
+ * Serializes a value for use as a shell environment variable. Strings pass through
+ * unchanged; primitives are coerced via `String`; objects, arrays, and other complex
+ * values are JSON-stringified so that referencing them via `$VAR` yields a useful
+ * representation instead of `[object Object]`. `null`/`undefined` become an empty
+ * string to match POSIX env semantics.
+ */
+function serializeForShellEnv(value: unknown, nullValue = ''): string {
+ if (value === null || value === undefined) return nullValue
+ if (typeof value === 'string') return value
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
+ return String(value)
+ }
+ try {
+ return JSON.stringify(value) ?? ''
+ } catch {
+ return String(value)
+ }
+}
+
async function maybeExportSandboxFileToWorkspace(args: {
authUserId: string
workflowId?: string
@@ -722,6 +742,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
blockNameMapping = {},
blockOutputSchemas = {},
workflowVariables = {},
+ contextVariables: preResolvedContextVariables = {},
workflowId,
workspaceId,
isCustomTool = false,
@@ -746,6 +767,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
// For shell, env vars are injected as OS env vars via shellEnvs.
// Replace {{VAR}} placeholders with $VAR so the shell can access them natively.
resolvedCode = code.replace(/\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g, '$$$1')
+ // Carry pre-resolved block output variables (e.g. __blockRef_N) so they can be
+ // injected as shell env vars below. The executor replaces block references in the
+ // code with these names, so the values must be present at runtime.
+ contextVariables = { ...preResolvedContextVariables }
} else {
const codeResolution = resolveCodeVariables(
code,
@@ -758,7 +783,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
lang
)
resolvedCode = codeResolution.resolvedCode
- contextVariables = codeResolution.contextVariables
+ // Merge pre-resolved block output variables from the executor. These take precedence
+ // because they were produced by the resolver using full execution-state context
+ // (including loop/parallel scope) and should not be overwritten.
+ contextVariables = { ...codeResolution.contextVariables, ...preResolvedContextVariables }
}
let jsImports = ''
@@ -783,10 +811,10 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const shellEnvs: Record = {}
for (const [k, v] of Object.entries(envVars)) {
- shellEnvs[k] = String(v)
+ shellEnvs[k] = serializeForShellEnv(v)
}
for (const [k, v] of Object.entries(contextVariables)) {
- shellEnvs[k] = String(v)
+ shellEnvs[k] = serializeForShellEnv(v, 'null')
}
logger.info(`[${requestId}] E2B shell execution`, {
@@ -893,7 +921,9 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
prologue += `const environmentVariables = JSON.parse(${JSON.stringify(JSON.stringify(envVars))});\n`
prologueLineCount++
for (const [k, v] of Object.entries(contextVariables)) {
- prologue += `const ${k} = ${formatLiteralForCode(v, 'javascript')};\n`
+ prologue += `globalThis[${JSON.stringify(k)}] = ${formatLiteralForCode(v, 'javascript')};\n`
+ prologue += `const ${k} = globalThis[${JSON.stringify(k)}];\n`
+ prologueLineCount++
prologueLineCount++
}
diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts
index 9a3c22e8529..73cdcb8d674 100644
--- a/apps/sim/executor/execution/block-executor.ts
+++ b/apps/sim/executor/execution/block-executor.ts
@@ -44,7 +44,10 @@ import {
} from '@/executor/utils/iteration-context'
import { isJSONString } from '@/executor/utils/json'
import { filterOutputForLog } from '@/executor/utils/output-filter'
-import type { VariableResolver } from '@/executor/variables/resolver'
+import {
+ FUNCTION_BLOCK_CONTEXT_VARS_KEY,
+ type VariableResolver,
+} from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants'
@@ -115,7 +118,13 @@ export class BlockExecutor {
await validateBlockType(ctx.userId, ctx.workspaceId, blockType, ctx)
}
- resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
+ if (block.metadata?.id === BlockType.FUNCTION) {
+ const { resolvedInputs: fnInputs, contextVariables } =
+ this.resolver.resolveInputsForFunctionBlock(ctx, node.id, block.config.params, block)
+ resolvedInputs = { ...fnInputs, [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables }
+ } else {
+ resolvedInputs = this.resolver.resolveInputs(ctx, node.id, block.config.params, block)
+ }
if (blockLog) {
blockLog.input = this.sanitizeInputsForLog(resolvedInputs)
@@ -428,7 +437,11 @@ export class BlockExecutor {
const result: Record = {}
for (const [key, value] of Object.entries(inputs)) {
- if (SYSTEM_SUBBLOCK_IDS.includes(key) || key === 'triggerMode') {
+ if (
+ SYSTEM_SUBBLOCK_IDS.includes(key) ||
+ key === 'triggerMode' ||
+ key === FUNCTION_BLOCK_CONTEXT_VARS_KEY
+ ) {
continue
}
diff --git a/apps/sim/executor/handlers/function/function-handler.test.ts b/apps/sim/executor/handlers/function/function-handler.test.ts
index 0dcda1ce37d..384540cc7b2 100644
--- a/apps/sim/executor/handlers/function/function-handler.test.ts
+++ b/apps/sim/executor/handlers/function/function-handler.test.ts
@@ -3,6 +3,7 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
import { BlockType } from '@/executor/constants'
import { FunctionBlockHandler } from '@/executor/handlers/function/function-handler'
import type { ExecutionContext } from '@/executor/types'
+import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -73,10 +74,13 @@ describe('FunctionBlockHandler', () => {
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
+ contextVariables: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
+ userId: mockContext.userId,
isDeployedContext: mockContext.isDeployedContext,
+ enforceCredentialAccess: mockContext.enforceCredentialAccess,
},
}
const expectedOutput: any = { result: 'Success' }
@@ -110,10 +114,13 @@ describe('FunctionBlockHandler', () => {
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
+ contextVariables: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
+ userId: mockContext.userId,
isDeployedContext: mockContext.isDeployedContext,
+ enforceCredentialAccess: mockContext.enforceCredentialAccess,
},
}
const expectedOutput: any = { result: 'Success' }
@@ -140,10 +147,13 @@ describe('FunctionBlockHandler', () => {
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
+ contextVariables: {},
_context: {
workflowId: mockContext.workflowId,
workspaceId: mockContext.workspaceId,
+ userId: mockContext.userId,
isDeployedContext: mockContext.isDeployedContext,
+ enforceCredentialAccess: mockContext.enforceCredentialAccess,
},
}
@@ -168,6 +178,24 @@ describe('FunctionBlockHandler', () => {
expect(mockExecuteTool).toHaveBeenCalled()
})
+ it('should pass runtime context variables to function_execute', async () => {
+ const contextVariables = { __blockRef_0: { result: 'from-block' } }
+
+ await handler.execute(mockContext, mockBlock, {
+ code: 'return globalThis["__blockRef_0"]',
+ [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: contextVariables,
+ })
+
+ expect(mockExecuteTool).toHaveBeenCalledWith(
+ 'function_execute',
+ expect.objectContaining({
+ contextVariables,
+ }),
+ false,
+ mockContext
+ )
+ })
+
it('should handle tool error with no specific message', async () => {
const inputs = { code: 'some code' }
const errorResult = { success: false }
diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts
index 68302412bcb..c008a8d07ea 100644
--- a/apps/sim/executor/handlers/function/function-handler.ts
+++ b/apps/sim/executor/handlers/function/function-handler.ts
@@ -3,6 +3,7 @@ import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
import { BlockType } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
import { collectBlockData } from '@/executor/utils/block-data'
+import { FUNCTION_BLOCK_CONTEXT_VARS_KEY } from '@/executor/variables/resolver'
import type { SerializedBlock } from '@/serializer/types'
import { executeTool } from '@/tools'
@@ -25,6 +26,9 @@ export class FunctionBlockHandler implements BlockHandler {
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
+ const contextVariables =
+ (inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY] as Record | undefined) ?? {}
+
const result = await executeTool(
'function_execute',
{
@@ -36,6 +40,7 @@ export class FunctionBlockHandler implements BlockHandler {
blockData,
blockNameMapping,
blockOutputSchemas,
+ contextVariables,
_context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
diff --git a/apps/sim/executor/utils/reference-validation.ts b/apps/sim/executor/utils/reference-validation.ts
index 60e57b69639..18b2b76d89f 100644
--- a/apps/sim/executor/utils/reference-validation.ts
+++ b/apps/sim/executor/utils/reference-validation.ts
@@ -143,14 +143,14 @@ export function createCombinedPattern(): RegExp {
*/
export function replaceValidReferences(
template: string,
- replacer: (match: string) => string
+ replacer: (match: string, index: number, template: string) => string
): string {
const pattern = createReferencePattern()
- return template.replace(pattern, (match) => {
+ return template.replace(pattern, (match, _content, index) => {
if (!isLikelyReferenceSegment(match)) {
return match
}
- return replacer(match)
+ return replacer(match, index, template)
})
}
diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts
new file mode 100644
index 00000000000..915afa54ae6
--- /dev/null
+++ b/apps/sim/executor/variables/resolver.test.ts
@@ -0,0 +1,153 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { BlockType } from '@/executor/constants'
+import { ExecutionState } from '@/executor/execution/state'
+import type { ExecutionContext } from '@/executor/types'
+import { VariableResolver } from '@/executor/variables/resolver'
+import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
+
+function createBlock(id: string, name: string, type: string, params = {}): SerializedBlock {
+ return {
+ id,
+ metadata: { id: type, name },
+ position: { x: 0, y: 0 },
+ config: { tool: type, params },
+ inputs: {},
+ outputs: {
+ result: 'string',
+ items: 'json',
+ },
+ enabled: true,
+ }
+}
+
+function createResolver(language = 'javascript') {
+ const producer = createBlock('producer', 'Producer', BlockType.API)
+ const functionBlock = createBlock('function', 'Function', BlockType.FUNCTION, {
+ language,
+ })
+ const workflow: SerializedWorkflow = {
+ version: '1',
+ blocks: [producer, functionBlock],
+ connections: [],
+ loops: {},
+ parallels: {},
+ }
+ const state = new ExecutionState()
+ state.setBlockOutput('producer', {
+ result: 'hello world',
+ items: ['a', 'b'],
+ })
+ const ctx = {
+ blockStates: state.getBlockStates(),
+ blockLogs: [],
+ environmentVariables: {},
+ workflowVariables: {},
+ decisions: { router: new Map(), condition: new Map() },
+ loopExecutions: new Map(),
+ executedBlocks: new Set(),
+ activeExecutionPath: new Set(),
+ completedLoops: new Set(),
+ metadata: {},
+ } as ExecutionContext
+
+ return {
+ block: functionBlock,
+ ctx,
+ resolver: new VariableResolver(workflow, {}, state),
+ }
+}
+
+describe('VariableResolver function block inputs', () => {
+ it('returns empty inputs when params are missing', () => {
+ const { block, ctx, resolver } = createResolver()
+
+ const result = resolver.resolveInputsForFunctionBlock(ctx, 'function', undefined, block)
+
+ expect(result).toEqual({ resolvedInputs: {}, contextVariables: {} })
+ })
+
+ it('resolves JavaScript block references through globalThis context variables', () => {
+ const { block, ctx, resolver } = createResolver('javascript')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: 'return ' },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe('return globalThis["__blockRef_0"]')
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('resolves Python block references through globals lookup', () => {
+ const { block, ctx, resolver } = createResolver('python')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: 'return ' },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe('return globals()["__blockRef_0"]')
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+
+ it('uses separate Python context variables for repeated mutable references', () => {
+ const { block, ctx, resolver } = createResolver('python')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: 'a = \nb = \nreturn b' },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ 'a = globals()["__blockRef_0"]\nb = globals()["__blockRef_1"]\nreturn b'
+ )
+ expect(result.contextVariables).toEqual({
+ __blockRef_0: ['a', 'b'],
+ __blockRef_1: ['a', 'b'],
+ })
+ })
+
+ it('uses shell-safe expansions for block references', () => {
+ const { block, ctx, resolver } = createResolver('shell')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: 'echo suffix && echo ""' },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ `echo "\${__blockRef_0}"suffix && echo "\${__blockRef_1}"`
+ )
+ expect(result.contextVariables).toEqual({
+ __blockRef_0: 'hello world',
+ __blockRef_1: 'hello world',
+ })
+ })
+
+ it('ignores shell comment quotes when formatting later block references', () => {
+ const { block, ctx, resolver } = createResolver('shell')
+
+ const result = resolver.resolveInputsForFunctionBlock(
+ ctx,
+ 'function',
+ { code: "# don't confuse quote tracking\necho " },
+ block
+ )
+
+ expect(result.resolvedInputs.code).toBe(
+ `# don't confuse quote tracking\necho "\${__blockRef_0}"`
+ )
+ expect(result.contextVariables).toEqual({ __blockRef_0: 'hello world' })
+ })
+})
diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts
index 412dd549d39..2cc9fd89e5b 100644
--- a/apps/sim/executor/variables/resolver.ts
+++ b/apps/sim/executor/variables/resolver.ts
@@ -16,8 +16,13 @@ import {
import { WorkflowResolver } from '@/executor/variables/resolvers/workflow'
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
+/** Key used to carry pre-resolved context variables through the inputs map. */
+export const FUNCTION_BLOCK_CONTEXT_VARS_KEY = '_runtimeContextVars'
+
const logger = createLogger('VariableResolver')
+type ShellQuoteContext = 'single' | 'double' | null
+
export class VariableResolver {
private resolvers: Resolver[]
private blockResolver: BlockResolver
@@ -37,6 +42,67 @@ export class VariableResolver {
]
}
+ /**
+ * Resolves inputs for function blocks. Block output references in the `code` field
+ * are stored as named context variables instead of being embedded as JavaScript
+ * literals, preventing large values from bloating the code string.
+ *
+ * Returns the resolved inputs and a `contextVariables` map. Callers should inject
+ * contextVariables into the function execution request body so the isolated VM can
+ * access them as global variables.
+ */
+ resolveInputsForFunctionBlock(
+ ctx: ExecutionContext,
+ currentNodeId: string,
+ params: Record | null | undefined,
+ block: SerializedBlock
+ ): { resolvedInputs: Record; contextVariables: Record } {
+ const contextVariables: Record = {}
+ const resolved: Record = {}
+
+ if (!params) {
+ return { resolvedInputs: resolved, contextVariables }
+ }
+
+ for (const [key, value] of Object.entries(params)) {
+ if (key === 'code') {
+ if (typeof value === 'string') {
+ resolved[key] = this.resolveCodeWithContextVars(
+ ctx,
+ currentNodeId,
+ value,
+ undefined,
+ block,
+ contextVariables
+ )
+ } else if (Array.isArray(value)) {
+ resolved[key] = value.map((item: any) => {
+ if (item && typeof item === 'object' && typeof item.content === 'string') {
+ return {
+ ...item,
+ content: this.resolveCodeWithContextVars(
+ ctx,
+ currentNodeId,
+ item.content,
+ undefined,
+ block,
+ contextVariables
+ ),
+ }
+ }
+ return item
+ })
+ } else {
+ resolved[key] = this.resolveValue(ctx, currentNodeId, value, undefined, block)
+ }
+ } else {
+ resolved[key] = this.resolveValue(ctx, currentNodeId, value, undefined, block)
+ }
+ }
+
+ return { resolvedInputs: resolved, contextVariables }
+ }
+
resolveInputs(
ctx: ExecutionContext,
currentNodeId: string,
@@ -150,6 +216,170 @@ export class VariableResolver {
}
return value
}
+ /**
+ * Resolves a code template for a function block. Block output references are stored
+ * in `contextVarAccumulator` as named variables (e.g. `__blockRef_0`) and replaced
+ * with those variable names in the returned code string. Non-block references (loop
+ * items, workflow variables, env vars) are still inlined as literals so they remain
+ * available without any extra passing mechanism.
+ */
+ private resolveCodeWithContextVars(
+ ctx: ExecutionContext,
+ currentNodeId: string,
+ template: string,
+ loopScope: LoopScope | undefined,
+ block: SerializedBlock,
+ contextVarAccumulator: Record
+ ): string {
+ const resolutionContext: ResolutionContext = {
+ executionContext: ctx,
+ executionState: this.state,
+ currentNodeId,
+ loopScope,
+ }
+
+ const language = (block.config?.params as Record | undefined)?.language as
+ | string
+ | undefined
+
+ let replacementError: Error | null = null
+
+ let result = replaceValidReferences(template, (match, index) => {
+ if (replacementError) return match
+
+ try {
+ if (this.blockResolver.canResolve(match)) {
+ const resolved = this.resolveReference(match, resolutionContext)
+ if (resolved === undefined) return match
+
+ const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved
+
+ // Block output: store in contextVarAccumulator and replace the reference
+ // with language-specific runtime access to that stored value.
+ const varName = `__blockRef_${Object.keys(contextVarAccumulator).length}`
+ contextVarAccumulator[varName] = effectiveValue
+ const replacement = this.formatContextVariableReference(
+ varName,
+ language,
+ template,
+ index,
+ effectiveValue
+ )
+ return replacement
+ }
+
+ const resolved = this.resolveReference(match, resolutionContext)
+ if (resolved === undefined) return match
+
+ const effectiveValue = resolved === RESOLVED_EMPTY ? null : resolved
+
+ // Non-block reference (loop, parallel, workflow, env): embed as literal
+ return this.blockResolver.formatValueForBlock(effectiveValue, BlockType.FUNCTION, language)
+ } catch (error) {
+ replacementError = error instanceof Error ? error : new Error(String(error))
+ return match
+ }
+ })
+
+ if (replacementError !== null) {
+ throw replacementError
+ }
+
+ result = result.replace(createEnvVarPattern(), (match) => {
+ const resolved = this.resolveReference(match, resolutionContext)
+ return typeof resolved === 'string' ? resolved : match
+ })
+
+ return result
+ }
+
+ private formatContextVariableReference(
+ varName: string,
+ language: string | undefined,
+ template: string,
+ matchIndex: number,
+ value: unknown
+ ): string {
+ if (language === 'python') {
+ return `globals()[${JSON.stringify(varName)}]`
+ }
+
+ if (language === 'shell') {
+ return this.formatShellContextVariableReference(varName, template, matchIndex, value)
+ }
+
+ return `globalThis[${JSON.stringify(varName)}]`
+ }
+
+ private formatShellContextVariableReference(
+ varName: string,
+ template: string,
+ matchIndex: number,
+ value: unknown
+ ): string {
+ const expansion = `\${${varName}}`
+ const quoteContext = this.getShellQuoteContext(template, matchIndex)
+ if (quoteContext === 'double') {
+ return expansion
+ }
+
+ const shouldQuote =
+ quoteContext === 'single' ||
+ typeof value === 'string' ||
+ (typeof value === 'object' && value !== null) ||
+ Array.isArray(value)
+
+ if (!shouldQuote) {
+ return expansion
+ }
+
+ const quotedExpansion = `"${expansion}"`
+ if (quoteContext === 'single') {
+ return `'${quotedExpansion}'`
+ }
+
+ return quotedExpansion
+ }
+
+ private getShellQuoteContext(template: string, index: number): ShellQuoteContext {
+ let quoteContext: ShellQuoteContext = null
+
+ for (let i = 0; i < index; i++) {
+ const char = template[i]
+
+ if (quoteContext === null && this.isShellCommentStart(template, i)) {
+ const nextNewline = template.indexOf('\n', i + 1)
+ if (nextNewline === -1 || nextNewline >= index) {
+ break
+ }
+ i = nextNewline
+ continue
+ }
+
+ if (char === '\\' && quoteContext !== 'single') {
+ i++
+ continue
+ }
+
+ if (char === "'" && quoteContext !== 'double') {
+ quoteContext = quoteContext === 'single' ? null : 'single'
+ } else if (char === '"' && quoteContext !== 'single') {
+ quoteContext = quoteContext === 'double' ? null : 'double'
+ }
+ }
+
+ return quoteContext
+ }
+
+ private isShellCommentStart(template: string, index: number): boolean {
+ if (template[index] !== '#') {
+ return false
+ }
+
+ const previous = template[index - 1]
+ return previous === undefined || /\s|[;&|()<>]/.test(previous)
+ }
+
private resolveTemplate(
ctx: ExecutionContext,
currentNodeId: string,
diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts
index 08baa37b499..c4e7a277303 100644
--- a/apps/sim/lib/api/contracts/hotspots.ts
+++ b/apps/sim/lib/api/contracts/hotspots.ts
@@ -99,6 +99,7 @@ export const functionExecuteContract = defineRouteContract({
blockNameMapping: z.record(z.string(), z.string()).optional().default({}),
blockOutputSchemas: z.record(z.string(), unknownRecordSchema).optional().default({}),
workflowVariables: unknownRecordSchema.optional().default({}),
+ contextVariables: unknownRecordSchema.optional().default({}),
workflowId: z.string().optional(),
workspaceId: z.string().optional(),
userId: z.string().optional(),
diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts
index e1a966fe94d..73eb21de9e6 100644
--- a/apps/sim/tools/function/execute.test.ts
+++ b/apps/sim/tools/function/execute.test.ts
@@ -56,10 +56,17 @@ describe('Function Execute Tool', () => {
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
+ contextVariables: {},
isCustomTool: false,
language: 'javascript',
+ outputFormat: undefined,
+ outputMimeType: undefined,
+ outputPath: undefined,
+ outputSandboxPath: undefined,
+ outputTable: undefined,
timeout: 5000,
workflowId: undefined,
+ workspaceId: undefined,
userId: undefined,
})
})
@@ -85,9 +92,16 @@ describe('Function Execute Tool', () => {
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
+ contextVariables: {},
isCustomTool: false,
language: 'javascript',
+ outputFormat: undefined,
+ outputMimeType: undefined,
+ outputPath: undefined,
+ outputSandboxPath: undefined,
+ outputTable: undefined,
workflowId: undefined,
+ workspaceId: undefined,
userId: undefined,
})
})
@@ -105,9 +119,16 @@ describe('Function Execute Tool', () => {
blockData: {},
blockNameMapping: {},
blockOutputSchemas: {},
+ contextVariables: {},
isCustomTool: false,
language: 'javascript',
+ outputFormat: undefined,
+ outputMimeType: undefined,
+ outputPath: undefined,
+ outputSandboxPath: undefined,
+ outputTable: undefined,
workflowId: undefined,
+ workspaceId: undefined,
userId: undefined,
})
})
diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts
index 844b1c6d515..59873843cbb 100644
--- a/apps/sim/tools/function/execute.ts
+++ b/apps/sim/tools/function/execute.ts
@@ -128,6 +128,7 @@ export const functionExecuteTool: ToolConfig
blockNameMapping?: Record
blockOutputSchemas?: Record>
+ /** Pre-resolved block output variables from the executor, injected as VM globals. */
+ contextVariables?: Record
_context?: {
workflowId?: string
userId?: string
diff --git a/bun.lock b/bun.lock
index cd73fd610fd..979cb1eb048 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
From 1989a12ff3d6b840a2906ad83bb7cb45a6c89ff4 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 6 May 2026 10:35:38 -0700
Subject: [PATCH 02/17] improvement(func-exec): normalize inputs to match
schema (#4473)
---
apps/sim/executor/execution/block-executor.ts | 3 +-
apps/sim/executor/execution/executor.ts | 8 +-
.../executor/execution/snapshot-serializer.ts | 4 +-
apps/sim/executor/execution/snapshot.test.ts | 30 +++++++
apps/sim/executor/execution/snapshot.ts | 30 +++++--
.../executor/handlers/agent/agent-handler.ts | 9 +-
.../handlers/condition/condition-handler.ts | 5 +-
.../function/function-handler.test.ts | 22 +++++
.../handlers/function/function-handler.ts | 12 ++-
apps/sim/lib/core/utils/arrays.test.ts | 13 +++
apps/sim/lib/core/utils/arrays.ts | 10 +++
apps/sim/lib/core/utils/records.test.ts | 64 +++++++++++++
apps/sim/lib/core/utils/records.ts | 90 +++++++++++++++++++
.../lib/workflows/executor/execution-core.ts | 21 ++---
.../executor/queued-workflow-execution.ts | 2 +-
apps/sim/providers/utils.ts | 19 +++-
apps/sim/tools/function/execute.ts | 18 ++--
apps/sim/tools/utils.ts | 15 ++--
18 files changed, 325 insertions(+), 50 deletions(-)
create mode 100644 apps/sim/executor/execution/snapshot.test.ts
create mode 100644 apps/sim/lib/core/utils/arrays.test.ts
create mode 100644 apps/sim/lib/core/utils/arrays.ts
create mode 100644 apps/sim/lib/core/utils/records.test.ts
create mode 100644 apps/sim/lib/core/utils/records.ts
diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts
index 73cdcb8d674..3803e53ffd6 100644
--- a/apps/sim/executor/execution/block-executor.ts
+++ b/apps/sim/executor/execution/block-executor.ts
@@ -1,6 +1,7 @@
import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { redactApiKeys } from '@/lib/core/security/redaction'
+import { normalizeStringArray } from '@/lib/core/utils/arrays'
import { getBaseUrl } from '@/lib/core/utils/urls'
import {
containsUserFileWithMetadata,
@@ -164,7 +165,7 @@ export class BlockExecutor {
block,
streamingExec,
resolvedInputs,
- ctx.selectedOutputs ?? []
+ normalizeStringArray(ctx.selectedOutputs)
)
}
diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts
index 8e3a8c8c8c9..a141e017fb1 100644
--- a/apps/sim/executor/execution/executor.ts
+++ b/apps/sim/executor/execution/executor.ts
@@ -1,4 +1,6 @@
import { createLogger, type Logger } from '@sim/logger'
+import { normalizeStringArray } from '@/lib/core/utils/arrays'
+import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import { StartBlockPath } from '@/lib/workflows/triggers/triggers'
import type { DAG } from '@/executor/dag/builder'
import { DAGBuilder } from '@/executor/dag/builder'
@@ -56,9 +58,9 @@ export class DAGExecutor {
constructor(options: DAGExecutorOptions) {
this.workflow = options.workflow
- this.environmentVariables = options.envVarValues ?? {}
+ this.environmentVariables = normalizeStringRecord(options.envVarValues)
this.workflowInput = options.workflowInput ?? {}
- this.workflowVariables = options.workflowVariables ?? {}
+ this.workflowVariables = normalizeWorkflowVariables(options.workflowVariables)
this.contextExtensions = options.contextExtensions ?? {}
this.dagBuilder = new DAGBuilder()
this.execLogger = logger.withMetadata({
@@ -325,7 +327,7 @@ export class DAGExecutor {
: new Set(),
workflow: this.workflow,
stream: this.contextExtensions.stream ?? false,
- selectedOutputs: this.contextExtensions.selectedOutputs ?? [],
+ selectedOutputs: normalizeStringArray(this.contextExtensions.selectedOutputs),
edges: this.contextExtensions.edges ?? [],
onStream: this.contextExtensions.onStream,
onBlockStart: this.contextExtensions.onBlockStart,
diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts
index 052d4b284b3..76c2a3dba5f 100644
--- a/apps/sim/executor/execution/snapshot-serializer.ts
+++ b/apps/sim/executor/execution/snapshot-serializer.ts
@@ -119,8 +119,8 @@ export function serializePauseSnapshot(
executionMetadata,
context.workflow,
{},
- context.workflowVariables ?? {},
- context.selectedOutputs ?? [],
+ context.workflowVariables,
+ context.selectedOutputs,
state
)
diff --git a/apps/sim/executor/execution/snapshot.test.ts b/apps/sim/executor/execution/snapshot.test.ts
new file mode 100644
index 00000000000..23c7d110cdf
--- /dev/null
+++ b/apps/sim/executor/execution/snapshot.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+import { ExecutionSnapshot } from '@/executor/execution/snapshot'
+import type { ExecutionMetadata } from '@/executor/execution/types'
+
+const metadata: ExecutionMetadata = {
+ requestId: 'request-1',
+ executionId: 'execution-1',
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ userId: 'user-1',
+ triggerType: 'manual',
+ startTime: '2026-05-06T00:00:00.000Z',
+}
+
+describe('ExecutionSnapshot', () => {
+ it('normalizes untyped persisted execution state at construction', () => {
+ const variable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
+
+ const snapshot = new ExecutionSnapshot(
+ metadata,
+ { blocks: [] },
+ {},
+ [variable],
+ ['agent.content', 123, 'function.result']
+ )
+
+ expect(snapshot.workflowVariables).toEqual({ 'var-1': variable })
+ expect(snapshot.selectedOutputs).toEqual(['agent.content', 'function.result'])
+ })
+})
diff --git a/apps/sim/executor/execution/snapshot.ts b/apps/sim/executor/execution/snapshot.ts
index afe9bf52d7f..6e372f97d73 100644
--- a/apps/sim/executor/execution/snapshot.ts
+++ b/apps/sim/executor/execution/snapshot.ts
@@ -1,14 +1,30 @@
+import { normalizeStringArray } from '@/lib/core/utils/arrays'
+import { normalizeWorkflowVariables } from '@/lib/core/utils/records'
import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types'
export class ExecutionSnapshot {
+ public readonly metadata: ExecutionMetadata
+ public readonly workflow: any
+ public readonly input: any
+ public readonly workflowVariables: Record
+ public readonly selectedOutputs: string[]
+ public readonly state?: SerializableExecutionState
+
constructor(
- public readonly metadata: ExecutionMetadata,
- public readonly workflow: any,
- public readonly input: any,
- public readonly workflowVariables: Record,
- public readonly selectedOutputs: string[] = [],
- public readonly state?: SerializableExecutionState
- ) {}
+ metadata: ExecutionMetadata,
+ workflow: any,
+ input: any,
+ workflowVariables: unknown,
+ selectedOutputs: unknown = [],
+ state?: SerializableExecutionState
+ ) {
+ this.metadata = metadata
+ this.workflow = workflow
+ this.input = input
+ this.workflowVariables = normalizeWorkflowVariables(workflowVariables)
+ this.selectedOutputs = normalizeStringArray(selectedOutputs)
+ this.state = state
+ }
toJSON(): string {
return JSON.stringify({
diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts
index b0384a0b7c4..e09b75387f7 100644
--- a/apps/sim/executor/handlers/agent/agent-handler.ts
+++ b/apps/sim/executor/handlers/agent/agent-handler.ts
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { and, eq, inArray, isNull } from 'drizzle-orm'
+import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import { createMcpToolId } from '@/lib/mcp/utils'
import { getCustomToolById } from '@/lib/workflows/custom-tools/operations'
import { getAllBlocks } from '@/blocks'
@@ -815,8 +816,8 @@ export class AgentBlockHandler implements BlockHandler {
userId: ctx.userId,
stream: streaming,
messages: messages?.map(({ executionId, ...msg }) => msg),
- environmentVariables: ctx.environmentVariables || {},
- workflowVariables: ctx.workflowVariables || {},
+ environmentVariables: normalizeStringRecord(ctx.environmentVariables),
+ workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData,
blockNameMapping,
reasoningEffort: inputs.reasoningEffort,
@@ -885,8 +886,8 @@ export class AgentBlockHandler implements BlockHandler {
userId: ctx.userId,
stream: providerRequest.stream,
messages: 'messages' in providerRequest ? providerRequest.messages : undefined,
- environmentVariables: ctx.environmentVariables || {},
- workflowVariables: ctx.workflowVariables || {},
+ environmentVariables: normalizeStringRecord(ctx.environmentVariables),
+ workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData,
blockNameMapping,
isDeployedContext: ctx.isDeployedContext,
diff --git a/apps/sim/executor/handlers/condition/condition-handler.ts b/apps/sim/executor/handlers/condition/condition-handler.ts
index 60ad9f99860..9fd4dd50f3c 100644
--- a/apps/sim/executor/handlers/condition/condition-handler.ts
+++ b/apps/sim/executor/handlers/condition/condition-handler.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { normalizeStringRecord, normalizeWorkflowVariables } from '@/lib/core/utils/records'
import type { BlockOutput } from '@/blocks/types'
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
import type { BlockHandler, ExecutionContext } from '@/executor/types'
@@ -40,8 +41,8 @@ export async function evaluateConditionExpression(
{
code,
timeout: CONDITION_TIMEOUT_MS,
- envVars: ctx.environmentVariables || {},
- workflowVariables: ctx.workflowVariables || {},
+ envVars: normalizeStringRecord(ctx.environmentVariables),
+ workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData,
blockNameMapping,
blockOutputSchemas,
diff --git a/apps/sim/executor/handlers/function/function-handler.test.ts b/apps/sim/executor/handlers/function/function-handler.test.ts
index 384540cc7b2..d7a4e19929f 100644
--- a/apps/sim/executor/handlers/function/function-handler.test.ts
+++ b/apps/sim/executor/handlers/function/function-handler.test.ts
@@ -196,6 +196,28 @@ describe('FunctionBlockHandler', () => {
)
})
+ it('should normalize malformed execution context records before calling function_execute', async () => {
+ const legacyVariable = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
+ mockContext.workflowVariables = [legacyVariable] as unknown as Record
+ mockContext.environmentVariables = ['invalid-env'] as unknown as Record
+
+ await handler.execute(mockContext, mockBlock, {
+ code: 'return "myfitness"',
+ [FUNCTION_BLOCK_CONTEXT_VARS_KEY]: ['invalid-context'],
+ })
+
+ expect(mockExecuteTool).toHaveBeenCalledWith(
+ 'function_execute',
+ expect.objectContaining({
+ envVars: {},
+ workflowVariables: { 'var-1': legacyVariable },
+ contextVariables: {},
+ }),
+ false,
+ mockContext
+ )
+ })
+
it('should handle tool error with no specific message', async () => {
const inputs = { code: 'some code' }
const errorResult = { success: false }
diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts
index c008a8d07ea..5cd01e1aa1f 100644
--- a/apps/sim/executor/handlers/function/function-handler.ts
+++ b/apps/sim/executor/handlers/function/function-handler.ts
@@ -1,3 +1,8 @@
+import {
+ normalizeRecord,
+ normalizeStringRecord,
+ normalizeWorkflowVariables,
+} from '@/lib/core/utils/records'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
import { BlockType } from '@/executor/constants'
@@ -26,8 +31,7 @@ export class FunctionBlockHandler implements BlockHandler {
const { blockData, blockNameMapping, blockOutputSchemas } = collectBlockData(ctx)
- const contextVariables =
- (inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY] as Record | undefined) ?? {}
+ const contextVariables = normalizeRecord(inputs[FUNCTION_BLOCK_CONTEXT_VARS_KEY])
const result = await executeTool(
'function_execute',
@@ -35,8 +39,8 @@ export class FunctionBlockHandler implements BlockHandler {
code: codeContent,
language: inputs.language || DEFAULT_CODE_LANGUAGE,
timeout: inputs.timeout || DEFAULT_EXECUTION_TIMEOUT_MS,
- envVars: ctx.environmentVariables || {},
- workflowVariables: ctx.workflowVariables || {},
+ envVars: normalizeStringRecord(ctx.environmentVariables),
+ workflowVariables: normalizeWorkflowVariables(ctx.workflowVariables),
blockData,
blockNameMapping,
blockOutputSchemas,
diff --git a/apps/sim/lib/core/utils/arrays.test.ts b/apps/sim/lib/core/utils/arrays.test.ts
new file mode 100644
index 00000000000..54062fa7f8d
--- /dev/null
+++ b/apps/sim/lib/core/utils/arrays.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeStringArray } from '@/lib/core/utils/arrays'
+
+describe('array normalization utilities', () => {
+ it('normalizes string arrays loaded from untyped state', () => {
+ expect(normalizeStringArray(['output-1', 2, 'output-2', null])).toEqual([
+ 'output-1',
+ 'output-2',
+ ])
+ expect(normalizeStringArray('output-1')).toEqual([])
+ expect(normalizeStringArray(undefined)).toEqual([])
+ })
+})
diff --git a/apps/sim/lib/core/utils/arrays.ts b/apps/sim/lib/core/utils/arrays.ts
new file mode 100644
index 00000000000..ffc270d6081
--- /dev/null
+++ b/apps/sim/lib/core/utils/arrays.ts
@@ -0,0 +1,10 @@
+/**
+ * Normalizes optional string-list values loaded from untyped persisted state.
+ */
+export function normalizeStringArray(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return []
+ }
+
+ return value.filter((item): item is string => typeof item === 'string')
+}
diff --git a/apps/sim/lib/core/utils/records.test.ts b/apps/sim/lib/core/utils/records.test.ts
new file mode 100644
index 00000000000..97a46f0e5dc
--- /dev/null
+++ b/apps/sim/lib/core/utils/records.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it } from 'vitest'
+import {
+ isPlainRecord,
+ normalizeRecord,
+ normalizeRecordMap,
+ normalizeStringRecord,
+ normalizeWorkflowVariables,
+} from '@/lib/core/utils/records'
+
+describe('record normalization utilities', () => {
+ it('identifies plain records without accepting arrays or null', () => {
+ expect(isPlainRecord({})).toBe(true)
+ expect(isPlainRecord(Object.create(null))).toBe(true)
+ expect(isPlainRecord([])).toBe(false)
+ expect(isPlainRecord(null)).toBe(false)
+ })
+
+ it('normalizes unknown values to object records', () => {
+ expect(normalizeRecord({ value: 1 })).toEqual({ value: 1 })
+ expect(normalizeRecord([])).toEqual({})
+ expect(normalizeRecord('not-a-record')).toEqual({})
+ })
+
+ it('normalizes string records for environment-like values', () => {
+ expect(
+ normalizeStringRecord({
+ TOKEN: 'secret',
+ RETRIES: 3,
+ ENABLED: true,
+ EMPTY: null,
+ })
+ ).toEqual({
+ TOKEN: 'secret',
+ RETRIES: '3',
+ ENABLED: 'true',
+ })
+ expect(normalizeStringRecord([])).toEqual({})
+ })
+
+ it('normalizes record maps by dropping malformed entries', () => {
+ expect(
+ normalizeRecordMap({
+ valid: { type: 'string' },
+ invalid: [],
+ })
+ ).toEqual({
+ valid: { type: 'string' },
+ })
+ })
+
+ it('normalizes legacy workflow variable arrays into records', () => {
+ const variableWithId = { id: 'var-1', name: 'brand', type: 'plain', value: 'myfitness' }
+ const variableWithName = { name: 'channel', type: 'plain', value: 'whatsapp' }
+
+ expect(normalizeWorkflowVariables([variableWithId, variableWithName, []])).toEqual({
+ 'var-1': variableWithId,
+ channel: variableWithName,
+ })
+ expect(normalizeWorkflowVariables({ existing: variableWithId })).toEqual({
+ existing: variableWithId,
+ })
+ expect(normalizeWorkflowVariables('not-a-record')).toEqual({})
+ })
+})
diff --git a/apps/sim/lib/core/utils/records.ts b/apps/sim/lib/core/utils/records.ts
new file mode 100644
index 00000000000..f5e0c4842ca
--- /dev/null
+++ b/apps/sim/lib/core/utils/records.ts
@@ -0,0 +1,90 @@
+export type UnknownRecord = Record
+export type StringRecord = Record
+
+/**
+ * Returns true only for object-map values, excluding arrays and null.
+ */
+export function isPlainRecord(value: unknown): value is UnknownRecord {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ return false
+ }
+
+ const prototype = Object.getPrototypeOf(value)
+ return prototype === Object.prototype || prototype === null
+}
+
+/**
+ * Normalizes optional execution context maps to the record shape expected by
+ * internal API contracts.
+ */
+export function normalizeRecord(value: unknown): UnknownRecord {
+ return isPlainRecord(value) ? value : {}
+}
+
+/**
+ * Normalizes environment-like maps to string values, matching process/env
+ * semantics at execution boundaries.
+ */
+export function normalizeStringRecord(value: unknown): StringRecord {
+ if (!isPlainRecord(value)) {
+ return {}
+ }
+
+ const normalized: StringRecord = {}
+ for (const [key, entryValue] of Object.entries(value)) {
+ if (entryValue === undefined || entryValue === null) {
+ continue
+ }
+ normalized[key] = typeof entryValue === 'string' ? entryValue : String(entryValue)
+ }
+ return normalized
+}
+
+/**
+ * Normalizes record-of-record maps such as block output schema maps.
+ */
+export function normalizeRecordMap(value: unknown): Record {
+ if (!isPlainRecord(value)) {
+ return {}
+ }
+
+ const normalized: Record = {}
+ for (const [key, entryValue] of Object.entries(value)) {
+ if (isPlainRecord(entryValue)) {
+ normalized[key] = entryValue
+ }
+ }
+ return normalized
+}
+
+/**
+ * Workflow variables are stored as a record in current state, while some
+ * legacy and imported snapshots can carry an array of variable objects.
+ */
+export function normalizeWorkflowVariables(value: unknown): UnknownRecord {
+ if (isPlainRecord(value)) {
+ return value
+ }
+
+ if (!Array.isArray(value)) {
+ return {}
+ }
+
+ const normalized: UnknownRecord = {}
+ for (const variable of value) {
+ if (!isPlainRecord(variable)) {
+ continue
+ }
+
+ const id = typeof variable.id === 'string' && variable.id.trim() ? variable.id : undefined
+ const name =
+ typeof variable.name === 'string' && variable.name.trim() ? variable.name : undefined
+ const key = id ?? name
+
+ if (key) {
+ normalized[key] = variable
+ }
+ }
+
+ return normalized
+}
diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts
index aec5f956c50..22b58c5e707 100644
--- a/apps/sim/lib/workflows/executor/execution-core.ts
+++ b/apps/sim/lib/workflows/executor/execution-core.ts
@@ -7,6 +7,7 @@ import { createLogger } from '@sim/logger'
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
import type { Edge } from 'reactflow'
import { z } from 'zod'
+import { isPlainRecord } from '@/lib/core/utils/records'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { clearExecutionCancellation } from '@/lib/execution/cancellation'
import type { LoggingSession } from '@/lib/logs/execution/logging-session'
@@ -581,6 +582,16 @@ export async function executeWorkflowCore(
callChain: metadata.callChain,
}
+ for (const variable of Object.values(workflowVariables)) {
+ if (
+ isPlainRecord(variable) &&
+ variable.value !== undefined &&
+ typeof variable.type === 'string'
+ ) {
+ variable.value = parseVariableValueByType(variable.value, variable.type)
+ }
+ }
+
const executorInstance = new Executor({
workflow: serializedWorkflow,
envVarValues: decryptedEnvVars,
@@ -589,16 +600,6 @@ export async function executeWorkflowCore(
contextExtensions,
})
- // Convert initial workflow variables to their native types
- if (workflowVariables) {
- for (const [varId, variable] of Object.entries(workflowVariables)) {
- const v = variable as { value?: unknown; type?: string }
- if (v.value !== undefined && v.type) {
- v.value = parseVariableValueByType(v.value, v.type)
- }
- }
- }
-
const result = runFromBlock
? ((await executorInstance.executeFromBlock(
workflowId,
diff --git a/apps/sim/lib/workflows/executor/queued-workflow-execution.ts b/apps/sim/lib/workflows/executor/queued-workflow-execution.ts
index 06a851a3b53..0106e823195 100644
--- a/apps/sim/lib/workflows/executor/queued-workflow-execution.ts
+++ b/apps/sim/lib/workflows/executor/queued-workflow-execution.ts
@@ -138,7 +138,7 @@ export async function executeQueuedWorkflowJob(
payload.workflow,
payload.input,
payload.variables,
- payload.selectedOutputs ?? []
+ payload.selectedOutputs
)
let callbacks = {}
diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts
index 0d5b7be022f..780776aed36 100644
--- a/apps/sim/providers/utils.ts
+++ b/apps/sim/providers/utils.ts
@@ -5,6 +5,11 @@ import type { CompletionUsage } from 'openai/resources/completions'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { env } from '@/lib/core/config/env'
import { getBlacklistedProvidersFromEnv, isHosted } from '@/lib/core/config/feature-flags'
+import {
+ normalizeRecord,
+ normalizeStringRecord,
+ normalizeWorkflowVariables,
+} from '@/lib/core/utils/records'
import {
buildCanonicalIndex,
type CanonicalGroup,
@@ -1166,10 +1171,16 @@ export function prepareToolExecution(
},
}
: {}),
- ...(request.environmentVariables ? { envVars: request.environmentVariables } : {}),
- ...(request.workflowVariables ? { workflowVariables: request.workflowVariables } : {}),
- ...(request.blockData ? { blockData: request.blockData } : {}),
- ...(request.blockNameMapping ? { blockNameMapping: request.blockNameMapping } : {}),
+ ...(request.environmentVariables
+ ? { envVars: normalizeStringRecord(request.environmentVariables) }
+ : {}),
+ ...(request.workflowVariables
+ ? { workflowVariables: normalizeWorkflowVariables(request.workflowVariables) }
+ : {}),
+ ...(request.blockData ? { blockData: normalizeRecord(request.blockData) } : {}),
+ ...(request.blockNameMapping
+ ? { blockNameMapping: normalizeStringRecord(request.blockNameMapping) }
+ : {}),
...(tool.parameters ? { _toolSchema: tool.parameters } : {}),
}
diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts
index 59873843cbb..7ee26f5c4b3 100644
--- a/apps/sim/tools/function/execute.ts
+++ b/apps/sim/tools/function/execute.ts
@@ -1,3 +1,9 @@
+import {
+ normalizeRecord,
+ normalizeRecordMap,
+ normalizeStringRecord,
+ normalizeWorkflowVariables,
+} from '@/lib/core/utils/records'
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages'
import type { CodeExecutionInput, CodeExecutionOutput } from '@/tools/function/types'
@@ -123,12 +129,12 @@ export const functionExecuteTool: ToolConfig
Date: Wed, 6 May 2026 10:38:34 -0700
Subject: [PATCH 03/17] feat(models): add grok-4.3 (#4472)
---
apps/sim/providers/models.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts
index 83ba847f77e..e75569c6486 100644
--- a/apps/sim/providers/models.ts
+++ b/apps/sim/providers/models.ts
@@ -1599,6 +1599,21 @@ export const PROVIDER_DEFINITIONS: Record = {
toolUsageControl: true,
},
models: [
+ {
+ id: 'grok-4.3',
+ pricing: {
+ input: 1.25,
+ cachedInput: 0.2,
+ output: 2.5,
+ updatedAt: '2026-05-05',
+ },
+ capabilities: {
+ temperature: { min: 0, max: 1 },
+ },
+ contextWindow: 1000000,
+ releaseDate: '2026-04-30',
+ recommended: true,
+ },
{
id: 'grok-4-latest',
pricing: {
From 5d38222a14c3a443c034497efa7a7648998a2aa9 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 6 May 2026 10:57:35 -0700
Subject: [PATCH 04/17] fix(function): validate custom tool param keys before
code interpolation (#4474)
* fix(function): validate custom tool param keys before code interpolation
* fix(function): exclude JS reserved words from param key injection guard
---
apps/sim/app/api/function/execute/route.ts | 63 +++++++++++++++++++++-
1 file changed, 61 insertions(+), 2 deletions(-)
diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts
index bd18d5686cd..c45a4bef7b7 100644
--- a/apps/sim/app/api/function/execute/route.ts
+++ b/apps/sim/app/api/function/execute/route.ts
@@ -34,6 +34,59 @@ const TAG_PATTERN = createReferencePattern()
const E2B_JS_WRAPPER_LINES = 3
const E2B_PYTHON_WRAPPER_LINES = 1
+/** Matches valid JS identifier names (letters, digits, underscore; no leading digit). */
+const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/
+
+/** ES2023 reserved words — using these as `const` variable names produces a SyntaxError. */
+const JS_RESERVED_WORDS = new Set([
+ 'break',
+ 'case',
+ 'catch',
+ 'class',
+ 'const',
+ 'continue',
+ 'debugger',
+ 'default',
+ 'delete',
+ 'do',
+ 'else',
+ 'export',
+ 'extends',
+ 'false',
+ 'finally',
+ 'for',
+ 'function',
+ 'if',
+ 'import',
+ 'in',
+ 'instanceof',
+ 'let',
+ 'new',
+ 'null',
+ 'return',
+ 'static',
+ 'super',
+ 'switch',
+ 'this',
+ 'throw',
+ 'true',
+ 'try',
+ 'typeof',
+ 'var',
+ 'void',
+ 'while',
+ 'with',
+ 'yield',
+ 'enum',
+ 'await',
+ 'implements',
+ 'interface',
+ 'package',
+ 'private',
+ 'protected',
+ 'public',
+])
+
type TypeScriptModule = typeof import('typescript')
let typescriptModulePromise: Promise | null = null
@@ -1089,10 +1142,16 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const executionMethod = 'isolated-vm'
+ const isSafeParamKey = (key: string) => SAFE_IDENTIFIER.test(key) && !JS_RESERVED_WORDS.has(key)
+
const wrapperLines = ['(async () => {', ' try {']
if (isCustomTool) {
Object.keys(executionParams).forEach((key) => {
- wrapperLines.push(` const ${key} = params.${key};`)
+ if (isSafeParamKey(key)) {
+ wrapperLines.push(` const ${key} = params.${key};`)
+ } else {
+ logger.warn('Skipping param key — not a safe JS identifier', { key, requestId })
+ }
})
}
userCodeStartLine = wrapperLines.length + 1
@@ -1100,7 +1159,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
let codeToExecute = resolvedCode
let prependedLineCount = 0
if (isCustomTool) {
- const paramKeys = Object.keys(executionParams)
+ const paramKeys = Object.keys(executionParams).filter(isSafeParamKey)
const paramDestructuring = paramKeys.map((key) => `const ${key} = params.${key};`).join('\n')
codeToExecute = `${paramDestructuring}\n${resolvedCode}`
prependedLineCount = paramKeys.length
From ad88859d6e2c1adffef4c175e67f3059219c7141 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 6 May 2026 10:57:47 -0700
Subject: [PATCH 05/17] chore(skills): add /add-model and /validate-model
commands (#4475)
---
.claude/commands/add-model.md | 159 +++++++++++++++++++++++++++
.claude/commands/validate-model.md | 166 +++++++++++++++++++++++++++++
2 files changed, 325 insertions(+)
create mode 100644 .claude/commands/add-model.md
create mode 100644 .claude/commands/validate-model.md
diff --git a/.claude/commands/add-model.md b/.claude/commands/add-model.md
new file mode 100644
index 00000000000..1fcf828537c
--- /dev/null
+++ b/.claude/commands/add-model.md
@@ -0,0 +1,159 @@
+---
+description: Add a new LLM model to apps/sim/providers/models.ts with specs verified against the provider's live API docs (no hallucination)
+argument-hint: [docs-url]
+---
+
+# Add Model Skill
+
+You add a new model entry to `apps/sim/providers/models.ts`. **Every numeric and capability claim MUST be derived from a live web fetch of the provider's official docs in this session.** Marketing emails, training data, and your prior knowledge are not sources of truth — they routinely hallucinate pricing, context windows, and capability lists.
+
+## Hard rules (do not skip)
+
+1. **Live-fetch or refuse.** Before writing the entry, you must successfully WebFetch the provider's official models/pricing page in this session. If you cannot reach an authoritative source for any field, **mark the field as UNVERIFIED in your report and ask the user before guessing**. Never fill in pricing or capabilities from memory.
+2. **Two-source rule for pricing.** Cross-check input/output/cached pricing against at least one secondary source (OpenRouter, Artificial Analysis, CloudPrice, mem0, intuitionlabs). If sources disagree, the provider's own docs win — but flag the disagreement.
+3. **Read the code before setting capability flags.** Capability flags are dead unless the provider's implementation under `apps/sim/providers/{provider}/` actually consumes them (see Consumption Matrix below). Setting a flag the provider ignores is a silent bug.
+4. **Cite every fact.** Your final report must list the URL each value came from. No URL → not verified.
+
+## Your Task
+
+1. Identify provider and model id from user args
+2. Live-fetch official docs + pricing page + capability/parameter pages + at least one secondary source
+3. Apply the Consumption Matrix to know which capability flags are real
+4. Read 2-3 sibling entries in `models.ts` and match their pattern exactly
+5. Insert the entry, run `bun run lint`, print the verification report
+
+## Step 1: Live source-of-truth lookup
+
+In priority order — fetch all that exist for the provider:
+
+| Provider | Models index | Pricing | Reasoning/parameter caveats |
+|---|---|---|---|
+| OpenAI | platform.openai.com/docs/models | openai.com/api/pricing | platform.openai.com/docs/guides/reasoning |
+| Anthropic | docs.anthropic.com/en/docs/about-claude/models | anthropic.com/pricing | docs.anthropic.com/en/docs/build-with-claude/extended-thinking |
+| Google (Gemini) | ai.google.dev/gemini-api/docs/models | ai.google.dev/pricing | ai.google.dev/gemini-api/docs/thinking |
+| xAI | docs.x.ai/developers/models | docs.x.ai/developers/models (per-model detail page) | docs.x.ai/developers/model-capabilities/text/reasoning |
+| Mistral | docs.mistral.ai/getting-started/models/models_overview | mistral.ai/pricing | n/a |
+| DeepSeek | api-docs.deepseek.com/quick_start/pricing | same | api-docs.deepseek.com/guides/reasoning_model |
+| Groq | console.groq.com/docs/models | groq.com/pricing | n/a |
+| Cerebras | inference-docs.cerebras.ai/models | cerebras.ai/pricing | n/a |
+
+Secondary verification (use at least one): `openrouter.ai//`, `artificialanalysis.ai/models/`, `cloudprice.net/models/-`.
+
+Use a precise WebFetch prompt: *"Extract for {model_id}: exact model id string, context window in tokens, input price per 1M, cached input price per 1M, output price per 1M, max output tokens, supported reasoning effort levels, accepted parameters (temperature, top_p), release date. Do not fill in fields you cannot find."*
+
+## Step 2: Consumption Matrix (which provider honors which capability)
+
+| Capability | Honored by | Effect if set elsewhere |
+|---|---|---|
+| `temperature` | All providers (passed through if set) | Safe but inert on always-reasoning models that reject it |
+| `toolUsageControl` | All providers (provider-level, not per-model) | n/a — set on `ProviderDefinition`, not models |
+| `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped to thinking), `gemini/core.ts` | **Dead on xai, deepseek, mistral, groq, cerebras, openrouter, fireworks, bedrock, vertex** unless their core consumes it — re-grep before assuming |
+| `verbosity` | `openai/core.ts`, `azure-openai/index.ts` only | Dead elsewhere |
+| `thinking` | `anthropic/core.ts`, `gemini/core.ts` | Dead elsewhere |
+| `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` | Dead on openai, xai, google, vertex, bedrock, azure-openai, deepseek, mistral, groq, cerebras |
+| `maxOutputTokens` | Read by UI + executor for token estimation | Always meaningful — set if provider documents a cap |
+| `computerUse` | `anthropic/core.ts` | Dead elsewhere |
+| `deepResearch` | UI flag for routing to deep-research SKUs | Set only on actual deep-research model IDs |
+| `memory: false` | Conversation persistence opt-out | Set only when model genuinely cannot maintain history (e.g., deep-research) |
+
+**Always re-grep before relying on this table** — the codebase moves:
+
+```bash
+rg "reasoningEffort|reasoning_effort" apps/sim/providers//
+rg "verbosity" apps/sim/providers//
+rg "request\.thinking|thinking:" apps/sim/providers//
+rg "supportsNativeStructuredOutputs|nativeStructuredOutputs" apps/sim/providers//
+```
+
+## Step 3: Match the provider's existing entry pattern
+
+Open `apps/sim/providers/models.ts`, find `PROVIDER_DEFINITIONS[].models`, read 2-3 sibling entries. Match field order exactly:
+
+```ts
+{
+ id: '',
+ pricing: {
+ input: ,
+ cachedInput: , // omit if provider doesn't offer caching
+ output: ,
+ updatedAt: '',
+ },
+ capabilities: {
+ // only flags the provider actually consumes — see matrix
+ },
+ contextWindow: ,
+ releaseDate: '',
+ recommended: true, // only if new flagship; ask user before swapping
+ speedOptimized: true, // only on smallest/fastest tier
+ deprecated: true, // only on retired models
+}
+```
+
+### Reseller providers (azure-openai, azure-anthropic, vertex, bedrock, openrouter)
+
+Model id MUST be prefixed: `azure/`, `azure-anthropic/`, `vertex/`, `bedrock/`, `openrouter/`. Pricing usually mirrors the upstream provider but verify on the reseller's own pricing page.
+
+### Insertion order
+
+Within a family, newest first (matches existing convention: GPT-5.5 above GPT-5.4 above GPT-5.2). Across families, biggest/flagship at top of list.
+
+### `recommended` / `speedOptimized`
+
+- At most one or two `recommended: true` per provider — the current flagship(s).
+- If you're adding a new flagship, ask the user before removing `recommended` from the previous flagship. Never silently flip it.
+- `speedOptimized: true` only on the smallest/fastest tier (nano, flash-lite, haiku class).
+
+## Step 4: Write, lint
+
+```bash
+bun run lint
+```
+
+Lint must pass before reporting done. **If lint fails:** read the error, fix the syntax/typing issue in the entry you just wrote (do not delete the entry — it's the work product), re-run lint, and note the fix in a "Lint adjustments" line in the verification report. Never report done with lint failing.
+
+## Step 5: Verification report (mandatory format)
+
+End with this exact structure:
+
+```markdown
+### Verification —
+
+| Field | Value | Source URL | Status |
+|---|---|---|---|
+| `id` | `grok-4.3` | https://docs.x.ai/... | ✓ verified |
+| `contextWindow` | 1,000,000 | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified (2 sources agree) |
+| `input` | $1.25/M | https://docs.x.ai/... | ✓ verified |
+| `cachedInput` | $0.20/M | https://cloudprice.net/... | ⚠️ single source |
+| `output` | $2.50/M | https://docs.x.ai/... + https://openrouter.ai/... | ✓ verified |
+| `capabilities.temperature` | `{ min: 0, max: 1 }` | matches sibling entries | — pattern-match only |
+| `capabilities.reasoningEffort` | NOT SET | provider docs say API rejects it for this model | ✓ correctly omitted |
+| `releaseDate` | 2026-04-30 | https://docs.x.ai/... announcement | ✓ verified |
+
+**Disagreements**
+- _none_ OR _OpenRouter says X, provider docs say Y — used Y per provider rule_
+
+**Unverified fields**
+- _none_ OR _: could not find authoritative source — left as based on sibling pattern; please confirm_
+```
+
+If any row is ⚠️ single-source or "unverified," **state it plainly to the user and ask whether to proceed**. Do not silently merge.
+
+## What to do if you cannot find a source
+
+Omitting a field is **not the same as verifying it**. Any field you cannot confirm from a live fetch must be **both** omitted from the entry **and** listed as ❓ UNVERIFIED in the report's "Unverified fields" section, with the URLs you attempted. Then ask the user to confirm before merging.
+
+- Pricing missing → do NOT guess. Omit `cachedInput`. Mark ❓ UNVERIFIED. Ask the user for the price or the docs URL.
+- Context window missing → do NOT guess. Ask the user; mark ❓ UNVERIFIED.
+- Release date missing → omit the field; mark ❓ UNVERIFIED in the report.
+- Capability uncertain → omit the flag (safer than setting a dead/wrong one); mark ❓ UNVERIFIED so the user knows you didn't confirm it either way.
+
+## Anti-patterns this skill exists to prevent
+
+- ❌ Trusting a marketing email (xAI's grok-4.3 email claimed "3 reasoning efforts" but the API rejects `reasoning_effort` — verified by official docs only)
+- ❌ Setting `nativeStructuredOutputs: true` on xai/openai/google (dead — only anthropic/fireworks/openrouter consume it)
+- ❌ Setting `thinking` on non-Anthropic/non-Gemini providers
+- ❌ Setting `verbosity` on anything other than OpenAI gpt-5.x
+- ❌ Copying `pricing.updatedAt` from a sibling instead of using today's date
+- ❌ Inventing a `cachedInput` price by dividing input by 4 (varies by provider — find an explicit number)
+- ❌ Stamping `recommended: true` on the new model without removing it from the previous flagship
+- ❌ Reporting "done" with any UNVERIFIED row in the table
diff --git a/.claude/commands/validate-model.md b/.claude/commands/validate-model.md
new file mode 100644
index 00000000000..10c6aaa0b27
--- /dev/null
+++ b/.claude/commands/validate-model.md
@@ -0,0 +1,166 @@
+---
+description: Validate a model entry (or every model in a provider) in apps/sim/providers/models.ts against the provider's live API docs (no hallucination — reports what cannot be verified)
+argument-hint: [model-id]
+---
+
+# Validate Model Skill
+
+You audit one or more model entries in `apps/sim/providers/models.ts` against the provider's official live API docs. **Hallucinated pricing and capabilities are the #1 failure mode in this file.** Every numeric and capability claim must be re-derived from a live web fetch in this session — not from memory, not from training data, not from the user's marketing email.
+
+## Hard rules (do not skip)
+
+1. **Live-fetch or report unverified.** Each field must be backed by a live WebFetch in this session. If you cannot reach an authoritative URL for a field, mark it **UNVERIFIED** in the report — do not silently confirm it from memory.
+2. **Cite every fact.** Every value in the report must show the source URL it was checked against. No URL → mark UNVERIFIED.
+3. **Two-source rule for pricing.** Cross-check input/output/cached against at least one secondary source (OpenRouter, Artificial Analysis, CloudPrice). If sources disagree, the provider's own docs win — flag the disagreement.
+4. **Inspect provider implementation before flagging capability mismatches.** A capability flag in `models.ts` is dead unless the provider's code under `apps/sim/providers/{provider}/` consumes it (see Consumption Matrix below). Setting a flag the provider ignores is a warning, not a critical.
+5. **Never auto-fix without printing the diff.** Show the user the proposed diff before applying. Get confirmation.
+
+## Your Task
+
+When invoked as `/validate-model [model-id]`:
+
+1. Read the target entries from `models.ts`
+2. Live-fetch the provider's official models, pricing, and capability/reasoning pages + at least one secondary source for pricing
+3. Inspect the provider implementation to know which flags are actually consumed
+4. Run the checklist below per model
+5. Report findings (critical / warning / suggestion / unverified) with every cell linked to its source URL
+6. Offer to fix; on confirm, edit `models.ts` in a single pass and re-lint
+
+If `model-id` is omitted, validate every model in the provider.
+
+## Step 1: Read entries from `models.ts`
+
+Capture per model: `id`, full `pricing`, full `capabilities`, `contextWindow`, `releaseDate`, `recommended`, `speedOptimized`, `deprecated`.
+
+## Step 2: Live-fetch authoritative sources
+
+Use the canonical provider URL table in `add-model.md` (Step 1) as the single source of truth — fetch the models index, pricing, and reasoning/parameter caveats pages listed there for the target provider. If you update one table, update the other in the same change.
+
+Secondary cross-check (use at least one): OpenRouter, Artificial Analysis, CloudPrice.
+
+If a fetch fails (404, timeout, paywall), record the URL attempted and mark dependent fields UNVERIFIED.
+
+## Step 3: Build the consumption map for this provider
+
+Re-grep before trusting the snapshot below:
+
+```bash
+rg "reasoningEffort|reasoning_effort" apps/sim/providers//
+rg "verbosity" apps/sim/providers//
+rg "request\.thinking|thinking:" apps/sim/providers//
+rg "supportsNativeStructuredOutputs|nativeStructuredOutputs" apps/sim/providers//
+```
+
+Snapshot (verify before relying):
+
+| Capability | Consumed by |
+|---|---|
+| `reasoningEffort` | `openai/core.ts`, `azure-openai`, `anthropic/core.ts` (mapped via thinking), `gemini/core.ts` |
+| `verbosity` | `openai/core.ts`, `azure-openai/index.ts` |
+| `thinking` | `anthropic/core.ts`, `gemini/core.ts` |
+| `nativeStructuredOutputs` | `anthropic/core.ts`, `fireworks/index.ts`, `openrouter/index.ts` |
+| `computerUse` | `anthropic/core.ts` |
+| `temperature` | All providers (passthrough) |
+
+A flag set in `models.ts` but not in the consumption list for this provider = **warning: dead flag**.
+
+## Step 4: Run the checklist
+
+For each model, evaluate every row. Statuses: ✓ matches docs, ✗ disagrees, ⚠️ single-source, ❓ UNVERIFIED (could not fetch).
+
+### Identity
+- [ ] `id` exactly matches provider's API model identifier (case, dots, dashes, prefix for resellers)
+- [ ] `releaseDate` matches launch announcement
+- [ ] `deprecated: true` set if provider has announced retirement (or removed from active list)
+
+### Pricing (per 1M tokens, USD)
+- [ ] `pricing.input` matches provider pricing page
+- [ ] `pricing.output` matches provider pricing page
+- [ ] `pricing.cachedInput` matches provider's documented cached/prompt-cache rate (or is correctly omitted if no caching offered)
+- [ ] `pricing.updatedAt` is recent — warn if older than 60 days
+
+### Context & output limits
+- [ ] `contextWindow` matches docs (in tokens)
+- [ ] `capabilities.maxOutputTokens` matches documented output cap (or is correctly omitted if "no output limit")
+
+### Capabilities (each must be DOCUMENTED-AS-SUPPORTED **and** CONSUMED-BY-PROVIDER-CODE)
+- [ ] `temperature` — provider accepts it for this model (reasoning-always-on models often reject)
+- [ ] `reasoningEffort.values` — list matches docs; **omitted** for always-reasoning models that reject the parameter (e.g., grok-4.3, where xAI docs explicitly state `reasoning_effort` is not supported). Verify per model — some always-reasoning models (e.g., OpenAI's o-series) DO accept `reasoning_effort` and should keep the flag.
+- [ ] `verbosity.values` — only on OpenAI gpt-5.x family; values match docs
+- [ ] `thinking.levels` + `thinking.default` — only on Anthropic/Gemini; values match docs
+- [ ] `nativeStructuredOutputs` — only on anthropic/fireworks/openrouter; provider must document Structured Outputs / JSON-mode for this model
+- [ ] `toolUsageControl` — provider supports `tool_choice` semantics
+- [ ] `computerUse` — provider implements computer-use loop AND model is a computer-use SKU
+- [ ] `deepResearch` — only on actual deep-research SKUs
+- [ ] `memory: false` — only when the model genuinely cannot maintain conversation history
+
+### Flags
+- [ ] `recommended: true` — at most one or two per provider; should be current flagship
+- [ ] `speedOptimized: true` — only on smallest/fastest tier (nano / flash-lite / haiku class)
+
+## Step 5: Report (mandatory format)
+
+For each model, emit a table with one row per checklist item. Every row that claims ✓ must have a URL.
+
+```markdown
+### Validation —
+
+| Field | Repo | Live docs | Source URL | Status |
+|---|---|---|---|---|
+| `input` | $1.25/M | $1.25/M | https://docs.x.ai/... | ✓ |
+| `cachedInput` | $0.50/M | $0.20/M | https://cloudprice.net/... | ✗ stale (price cut not picked up) |
+| `reasoningEffort` | low/medium/high | rejected by API | https://docs.x.ai/.../reasoning | ✗ inert — selecting silently no-ops |
+| `contextWindow` | 1,000,000 | 1,000,000 | https://docs.x.ai/... + https://openrouter.ai/... | ✓ (2 sources) |
+| `releaseDate` | 2026-04-30 | not found in scraped pages | _attempted: docs.x.ai, x.ai/news_ | ❓ UNVERIFIED |
+
+**Findings**
+- 🔴 critical — `cachedInput` is wrong: docs say $0.20/M, repo has $0.50/M
+- 🟡 warning — `reasoningEffort` is set but provider rejects it for this model (xAI docs explicitly: "reasoning_effort is not supported by grok-4.3")
+- 🔵 suggestion — `pricing.updatedAt` is 90 days old; refresh
+- ❓ unverified — `releaseDate` could not be confirmed from any fetched page; ask user
+
+**Disagreements between sources**
+- _none_ OR _OpenRouter says $X, provider docs say $Y — went with provider docs_
+```
+
+End each multi-model run with a summary count: `N models checked · X critical · Y warnings · Z suggestions · W unverified`.
+
+## Step 6: Offer to fix
+
+After reporting, ask: *"Want me to fix the critical and warning items? I'll print the diff first."* On yes:
+
+1. Print the proposed diff (do not apply yet)
+2. Get user confirmation
+3. Edit `models.ts` in a single pass
+4. Run `bun run lint`
+5. Re-run only the failed rows of the checklist on the new state
+
+## Severity definitions
+
+- 🔴 **critical** — wrong number or wrong identifier that misleads users about cost or breaks API calls. Examples: incorrect pricing, wrong model id, wrong context window, capability the API rejects.
+- 🟡 **warning** — dead code or internal inconsistency. Examples: capability flag the provider ignores, multiple `recommended: true` per provider, `pricing.updatedAt` >60 days old, missing `deprecated: true` on retired model.
+- 🔵 **suggestion** — style/consistency. Examples: field order, missing `speedOptimized` on a clearly smallest-tier model.
+- ❓ **unverified** — could not fetch an authoritative source for this field. Surface it; never silently confirm.
+
+## Common bugs this skill catches
+
+- Pricing drift after a provider price cut (very common — providers cut quarterly)
+- `reasoningEffort` set on always-reasoning models that reject the parameter (grok-4.3, o3-pro pattern)
+- `nativeStructuredOutputs` set on providers that don't consume the flag (dead)
+- `thinking` set on non-Anthropic/non-Gemini providers
+- `verbosity` set on non-gpt-5.x models
+- Wrong context window (e.g., 128k claimed vs 200k actual)
+- Stale `pricing.updatedAt`
+- Multiple `recommended: true` per provider after a flagship swap
+- Missing `deprecated: true` on retired models (e.g., the xAI batch retiring May 15, 2026)
+
+## What "I cannot verify this" looks like
+
+If, after fetching the documented sources, a field cannot be confirmed:
+
+- Mark the row ❓ UNVERIFIED with the URL(s) attempted
+- Surface it in the **Findings** section with severity ❓
+- Do NOT mark the validation as passed
+- Ask the user for a docs URL or guidance before changing anything
+
+The skill is allowed to say *"I could not verify the cached input price for grok-4.3 from the official xAI docs in this session — I attempted [URLs] without finding the value. Third-party sources [URL1, URL2] both report $0.20/M. Confirm before I update."* That is correct behavior. Hallucinating a number is not.
From 48331451b05c7f031dcfb5770f99da13702eed72 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 6 May 2026 11:08:01 -0700
Subject: [PATCH 06/17] chore(deps): upgrade next.js to 16.2.4 (#4460)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* chore(deps): upgrade next.js to 16.2.4
- Bump next and @next/env to 16.2.4 across root, apps/sim, apps/docs
- Replace next-runtime-env's env() helper (calls unstable_noStore(), rejected by Next 16.2 outside request scope) with a direct window.__ENV / process.env getter
- Add export const dynamic = 'force-dynamic' on landing /privacy and /terms pages so NEXT_PUBLIC_* runtime env reads aren't baked at build
* fix(whitelabel): force dynamic rendering for manifest.ts
Without this, NEXT_PUBLIC_BRAND_* values are baked into the manifest at build time. Pairs with the next-runtime-env removal in the prior commit, restoring Docker runtime injection for whitelabel deployments.
* fix(oauth): wrap consent page useSearchParams in Suspense
Next 16.2's stricter prerender check fails the build when useSearchParams() is used without a Suspense boundary. Splits the client component into an outer wrapper and inner body.
* fix(whitelabel): force dynamic rendering for landing segment
Client components in (landing) (e.g. Navbar) read NEXT_PUBLIC_BRAND_* via getEnv. Without this, SSR prerender would bake the build-time process.env values into HTML, mismatching window.__ENV after hydration in Docker runtime-env deployments. Cascades to all landing routes via the layout.
* revert(whitelabel): drop force-dynamic from landing layout
Cascading force-dynamic neutered dynamicParams = false + generateStaticParams on /blog/[slug], /integrations/[slug], /models/[provider], /models/[provider]/[model] — killing static prerender for SEO-critical pages. The hydration concern only materializes for whitelabel Docker deployments where build-time and runtime NEXT_PUBLIC_BRAND_* differ; those deployments can set the vars at build instead. Keeping force-dynamic on /privacy, /terms, and /manifest where it actually matters.
* fix(prerender): wrap useSearchParams callsites for Next 16.2
Next 16.2 fails the build when a client component using useSearchParams() is statically prerendered without a Suspense boundary.
- Wrap landing Navbar in Suspense (imported by /oauth/consent and other pages)
- Add force-dynamic to reset-password, invite/[id], and unsubscribe pages whose client bodies call useSearchParams
* fix(navbar): preserve SSR HTML, drop Suspense bailout
Reading useSearchParams() forced a Suspense fallback that emitted no navbar HTML during SSR — leaving crawlers and no-JS users without nav. The 'home' query param only affects client-side link targets, so read it from window.location in an effect after hydration. Restores full SSR navbar markup.
* chore: trim verbose comments in next.js upgrade
The force-dynamic export name is self-documenting; the remaining env.ts comment is tightened to the essential WHY (why we don't use next-runtime-env's helper).
---
apps/docs/package.json | 2 +-
apps/sim/app/(auth)/oauth/consent/page.tsx | 10 ++++++-
apps/sim/app/(auth)/reset-password/page.tsx | 2 ++
.../(landing)/components/navbar/navbar.tsx | 7 +++--
apps/sim/app/(landing)/privacy/page.tsx | 2 ++
apps/sim/app/(landing)/terms/page.tsx | 2 ++
apps/sim/app/invite/[id]/page.tsx | 2 ++
apps/sim/app/manifest.ts | 2 ++
apps/sim/app/unsubscribe/page.tsx | 2 ++
apps/sim/lib/core/config/env.ts | 14 ++++++----
apps/sim/next.config.ts | 10 +++----
apps/sim/package.json | 6 ++--
bun.lock | 28 +++++++++----------
package.json | 4 +--
14 files changed, 58 insertions(+), 35 deletions(-)
diff --git a/apps/docs/package.json b/apps/docs/package.json
index d9d9a53f519..d614fe60b0a 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -26,7 +26,7 @@
"fumadocs-openapi": "10.8.1",
"fumadocs-ui": "16.8.5",
"lucide-react": "^0.511.0",
- "next": "16.1.6",
+ "next": "16.2.4",
"next-themes": "^0.4.6",
"postgres": "^3.4.5",
"react": "19.2.4",
diff --git a/apps/sim/app/(auth)/oauth/consent/page.tsx b/apps/sim/app/(auth)/oauth/consent/page.tsx
index 8addf4e82b4..ea624d9b87f 100644
--- a/apps/sim/app/(auth)/oauth/consent/page.tsx
+++ b/apps/sim/app/(auth)/oauth/consent/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useCallback, useEffect, useState } from 'react'
+import { Suspense, useCallback, useEffect, useState } from 'react'
import { ArrowLeftRight } from 'lucide-react'
import Image from 'next/image'
import { useRouter, useSearchParams } from 'next/navigation'
@@ -25,6 +25,14 @@ interface ClientInfo {
}
export default function OAuthConsentPage() {
+ return (
+
+
+
+ )
+}
+
+function OAuthConsentInner() {
const router = useRouter()
const searchParams = useSearchParams()
const { data: session } = useSession()
diff --git a/apps/sim/app/(auth)/reset-password/page.tsx b/apps/sim/app/(auth)/reset-password/page.tsx
index cb6470bba0e..8d9ee5dfd42 100644
--- a/apps/sim/app/(auth)/reset-password/page.tsx
+++ b/apps/sim/app/(auth)/reset-password/page.tsx
@@ -5,4 +5,6 @@ export const metadata: Metadata = {
title: 'Reset Password',
}
+export const dynamic = 'force-dynamic'
+
export default ResetPasswordPage
diff --git a/apps/sim/app/(landing)/components/navbar/navbar.tsx b/apps/sim/app/(landing)/components/navbar/navbar.tsx
index 4ee2fdb2aca..e5503dcb27e 100644
--- a/apps/sim/app/(landing)/components/navbar/navbar.tsx
+++ b/apps/sim/app/(landing)/components/navbar/navbar.tsx
@@ -4,7 +4,6 @@ import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalSt
import dynamic from 'next/dynamic'
import Image from 'next/image'
import Link from 'next/link'
-import { useSearchParams } from 'next/navigation'
import { GithubOutlineIcon } from '@/components/icons'
import { cn } from '@/lib/core/utils/cn'
import { SessionContext } from '@/app/_shell/providers/session-provider'
@@ -52,12 +51,14 @@ interface NavbarProps {
export default function Navbar({ logoOnly = false, blogPosts = [] }: NavbarProps) {
const brand = getBrandConfig()
- const searchParams = useSearchParams()
const sessionCtx = useContext(SessionContext)
const session = sessionCtx?.data ?? null
const isSessionPending = sessionCtx?.isPending ?? true
const isAuthenticated = Boolean(session?.user?.id)
- const isBrowsingHome = searchParams.has('home')
+ const [isBrowsingHome, setIsBrowsingHome] = useState(false)
+ useEffect(() => {
+ setIsBrowsingHome(new URLSearchParams(window.location.search).has('home'))
+ }, [])
const useHomeLinks = isAuthenticated || isBrowsingHome
const logoHref = useHomeLinks ? '/?home' : '/'
const mounted = useSyncExternalStore(
diff --git a/apps/sim/app/(landing)/privacy/page.tsx b/apps/sim/app/(landing)/privacy/page.tsx
index 44140c62bb6..1081c090b82 100644
--- a/apps/sim/app/(landing)/privacy/page.tsx
+++ b/apps/sim/app/(landing)/privacy/page.tsx
@@ -3,6 +3,8 @@ import Link from 'next/link'
import { getEnv } from '@/lib/core/config/env'
import { ExternalRedirect, LegalLayout } from '@/app/(landing)/components'
+export const dynamic = 'force-dynamic'
+
export const metadata: Metadata = {
title: 'Privacy Policy',
description:
diff --git a/apps/sim/app/(landing)/terms/page.tsx b/apps/sim/app/(landing)/terms/page.tsx
index 08c16aeaa14..5d49f2bc52a 100644
--- a/apps/sim/app/(landing)/terms/page.tsx
+++ b/apps/sim/app/(landing)/terms/page.tsx
@@ -3,6 +3,8 @@ import Link from 'next/link'
import { getEnv } from '@/lib/core/config/env'
import { ExternalRedirect, LegalLayout } from '@/app/(landing)/components'
+export const dynamic = 'force-dynamic'
+
export const metadata: Metadata = {
title: 'Terms of Service',
description:
diff --git a/apps/sim/app/invite/[id]/page.tsx b/apps/sim/app/invite/[id]/page.tsx
index e04a2ca7743..ac302790523 100644
--- a/apps/sim/app/invite/[id]/page.tsx
+++ b/apps/sim/app/invite/[id]/page.tsx
@@ -6,4 +6,6 @@ export const metadata: Metadata = {
robots: { index: false },
}
+export const dynamic = 'force-dynamic'
+
export default Invite
diff --git a/apps/sim/app/manifest.ts b/apps/sim/app/manifest.ts
index d66d2db1a17..cb91437f3c1 100644
--- a/apps/sim/app/manifest.ts
+++ b/apps/sim/app/manifest.ts
@@ -1,6 +1,8 @@
import type { MetadataRoute } from 'next'
import { getBrandConfig } from '@/ee/whitelabeling'
+export const dynamic = 'force-dynamic'
+
export default function manifest(): MetadataRoute.Manifest {
const brand = getBrandConfig()
diff --git a/apps/sim/app/unsubscribe/page.tsx b/apps/sim/app/unsubscribe/page.tsx
index d1b3ec2de10..2258e81c93e 100644
--- a/apps/sim/app/unsubscribe/page.tsx
+++ b/apps/sim/app/unsubscribe/page.tsx
@@ -6,4 +6,6 @@ export const metadata: Metadata = {
robots: { index: false },
}
+export const dynamic = 'force-dynamic'
+
export default Unsubscribe
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index b2c8c2871bd..4d138316597 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -1,14 +1,16 @@
import { createEnv } from '@t3-oss/env-nextjs'
-import { env as runtimeEnv } from 'next-runtime-env'
import { z } from 'zod'
/**
- * Universal environment variable getter that works in both client and server contexts.
- * - Client-side: Uses next-runtime-env for runtime injection (supports Docker runtime vars)
- * - Server-side: Falls back to process.env when runtimeEnv returns undefined
- * - Provides seamless Docker runtime variable support for NEXT_PUBLIC_ vars
+ * Reads NEXT_PUBLIC_* env vars in both client and server contexts.
+ * Client reads `window.__ENV` (populated by ``); server reads `process.env`.
+ * We do not use next-runtime-env's `env()` helper because it calls `unstable_noStore()`,
+ * which Next 16.2+ rejects outside a request scope.
*/
-const getEnv = (variable: string) => runtimeEnv(variable) ?? process.env[variable]
+const getEnv = (variable: string): string | undefined => {
+ if (typeof window === 'undefined') return process.env[variable]
+ return window.__ENV?.[variable] ?? process.env[variable]
+}
// biome-ignore format: keep alignment for readability
export const env = createEnv({
diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts
index e349e2c865b..7a96c063cfe 100644
--- a/apps/sim/next.config.ts
+++ b/apps/sim/next.config.ts
@@ -1,5 +1,5 @@
import type { NextConfig } from 'next'
-import { env, getEnv, isTruthy } from './lib/core/config/env'
+import { env, isTruthy } from './lib/core/config/env'
import { isDev } from './lib/core/config/feature-flags'
import {
getChatEmbedCSPPolicy,
@@ -40,13 +40,13 @@ const nextConfig: NextConfig = {
hostname: 'lh3.googleusercontent.com',
},
// Brand logo domain if configured
- ...(getEnv('NEXT_PUBLIC_BRAND_LOGO_URL')
+ ...(process.env.NEXT_PUBLIC_BRAND_LOGO_URL
? (() => {
try {
return [
{
protocol: 'https' as const,
- hostname: new URL(getEnv('NEXT_PUBLIC_BRAND_LOGO_URL')!).hostname,
+ hostname: new URL(process.env.NEXT_PUBLIC_BRAND_LOGO_URL!).hostname,
},
]
} catch {
@@ -55,13 +55,13 @@ const nextConfig: NextConfig = {
})()
: []),
// Brand favicon domain if configured
- ...(getEnv('NEXT_PUBLIC_BRAND_FAVICON_URL')
+ ...(process.env.NEXT_PUBLIC_BRAND_FAVICON_URL
? (() => {
try {
return [
{
protocol: 'https' as const,
- hostname: new URL(getEnv('NEXT_PUBLIC_BRAND_FAVICON_URL')!).hostname,
+ hostname: new URL(process.env.NEXT_PUBLIC_BRAND_FAVICON_URL!).hostname,
},
]
} catch {
diff --git a/apps/sim/package.json b/apps/sim/package.json
index 9d0c3e8a932..9eadf53bcd4 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -160,7 +160,7 @@
"mongodb": "6.19.0",
"mysql2": "3.14.3",
"neo4j-driver": "6.0.1",
- "next": "16.1.6",
+ "next": "16.2.4",
"next-mdx-remote": "^5.0.0",
"next-runtime-env": "3.3.0",
"next-themes": "^0.4.6",
@@ -250,8 +250,8 @@
"sharp"
],
"overrides": {
- "next": "16.1.6",
- "@next/env": "16.1.6",
+ "next": "16.2.4",
+ "@next/env": "16.2.4",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.5",
"react-floater": {
diff --git a/bun.lock b/bun.lock
index 979cb1eb048..ecaf2a67bbf 100644
--- a/bun.lock
+++ b/bun.lock
@@ -28,7 +28,7 @@
"fumadocs-openapi": "10.8.1",
"fumadocs-ui": "16.8.5",
"lucide-react": "^0.511.0",
- "next": "16.1.6",
+ "next": "16.2.4",
"next-themes": "^0.4.6",
"postgres": "^3.4.5",
"react": "19.2.4",
@@ -213,7 +213,7 @@
"mongodb": "6.19.0",
"mysql2": "3.14.3",
"neo4j-driver": "6.0.1",
- "next": "16.1.6",
+ "next": "16.2.4",
"next-mdx-remote": "^5.0.0",
"next-runtime-env": "3.3.0",
"next-themes": "^0.4.6",
@@ -482,9 +482,9 @@
"sharp",
],
"overrides": {
- "@next/env": "16.1.6",
+ "@next/env": "16.2.4",
"drizzle-orm": "^0.45.2",
- "next": "16.1.6",
+ "next": "16.2.4",
"postgres": "^3.4.5",
"react": "19.2.4",
"react-dom": "19.2.4",
@@ -1050,23 +1050,23 @@
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.100", "", { "os": "win32", "cpu": "x64" }, "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA=="],
- "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="],
+ "@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="],
- "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="],
+ "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="],
- "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="],
+ "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ=="],
- "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="],
+ "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ=="],
- "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="],
+ "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg=="],
- "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="],
+ "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ=="],
- "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="],
+ "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA=="],
- "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="],
+ "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow=="],
- "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="],
+ "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="],
"@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
@@ -3210,7 +3210,7 @@
"netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="],
- "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="],
+ "next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="],
"next-mdx-remote": ["next-mdx-remote@5.0.0", "", { "dependencies": { "@babel/code-frame": "^7.23.5", "@mdx-js/mdx": "^3.0.1", "@mdx-js/react": "^3.0.1", "unist-util-remove": "^3.1.0", "vfile": "^6.0.1", "vfile-matter": "^5.0.0" }, "peerDependencies": { "react": ">=16" } }, "sha512-RNNbqRpK9/dcIFZs/esQhuLA8jANqlH694yqoDBK8hkVdJUndzzGmnPHa2nyi90N4Z9VmzuSWNRpr5ItT3M7xQ=="],
diff --git a/package.json b/package.json
index 8e8b78e1802..eea53bc1299 100644
--- a/package.json
+++ b/package.json
@@ -48,8 +48,8 @@
"overrides": {
"react": "19.2.4",
"react-dom": "19.2.4",
- "next": "16.1.6",
- "@next/env": "16.1.6",
+ "next": "16.2.4",
+ "@next/env": "16.2.4",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.5"
},
From 6d4ffff327998065819dc7865446cce3e66e2c17 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 6 May 2026 11:55:11 -0700
Subject: [PATCH 07/17] fix(agiloft): correct response parsing, add
EWGetChoiceLineId tool (#4477)
* fix(agiloft): correct response parsing, add EWGetChoiceLineId tool
* fix(agiloft): address PR review feedback
---
apps/docs/content/docs/en/tools/agiloft.mdx | 26 +++-
apps/docs/content/docs/en/tools/posthog.mdx | 8 +-
.../integrations/data/integrations.json | 8 +-
.../sim/app/api/tools/agiloft/attach/route.ts | 8 +-
.../app/api/tools/agiloft/retrieve/route.ts | 6 +-
apps/sim/blocks/blocks/agiloft.ts | 39 +++++-
apps/sim/lib/api/contracts/tools/agiloft.ts | 4 +-
apps/sim/tools/agiloft/attachment_info.ts | 10 +-
apps/sim/tools/agiloft/create_record.ts | 2 +-
apps/sim/tools/agiloft/delete_record.ts | 1 +
apps/sim/tools/agiloft/get_choice_line_id.ts | 130 ++++++++++++++++++
apps/sim/tools/agiloft/index.ts | 1 +
apps/sim/tools/agiloft/read_record.ts | 1 +
apps/sim/tools/agiloft/remove_attachment.ts | 4 +-
apps/sim/tools/agiloft/saved_search.ts | 8 +-
apps/sim/tools/agiloft/search_records.ts | 59 ++++----
apps/sim/tools/agiloft/select_records.ts | 14 +-
apps/sim/tools/agiloft/types.ts | 11 ++
apps/sim/tools/agiloft/update_record.ts | 2 +-
apps/sim/tools/agiloft/utils.ts | 10 ++
apps/sim/tools/registry.ts | 2 +
21 files changed, 287 insertions(+), 67 deletions(-)
create mode 100644 apps/sim/tools/agiloft/get_choice_line_id.ts
diff --git a/apps/docs/content/docs/en/tools/agiloft.mdx b/apps/docs/content/docs/en/tools/agiloft.mdx
index 235300ea259..5032b74663d 100644
--- a/apps/docs/content/docs/en/tools/agiloft.mdx
+++ b/apps/docs/content/docs/en/tools/agiloft.mdx
@@ -7,7 +7,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
{/* MANUAL-CONTENT-START:intro */}
@@ -137,6 +137,28 @@ Delete a record from an Agiloft table.
| `id` | string | ID of the deleted record |
| `deleted` | boolean | Whether the record was successfully deleted |
+### `agiloft_get_choice_line_id`
+
+Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.
+
+#### Input
+
+| Parameter | Type | Required | Description |
+| --------- | ---- | -------- | ----------- |
+| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) |
+| `knowledgeBase` | string | Yes | Knowledge base name |
+| `login` | string | Yes | Agiloft username |
+| `password` | string | Yes | Agiloft password |
+| `table` | string | Yes | Table name \(e.g., "case", "contracts"\) |
+| `fieldName` | string | Yes | Choice field name \(e.g., "priority", "status"\) |
+| `value` | string | Yes | Choice display value to resolve \(e.g., "High", "Active"\) |
+
+#### Output
+
+| Parameter | Type | Description |
+| --------- | ---- | ----------- |
+| `choiceLineId` | number | Internal numeric line ID of the choice value |
+
### `agiloft_lock_record`
Lock, unlock, or check the lock status of an Agiloft record.
@@ -254,7 +276,7 @@ List saved searches defined for an Agiloft table.
| `searches` | array | List of saved searches for the table |
| ↳ `name` | string | Saved search name |
| ↳ `label` | string | Saved search display label |
-| ↳ `id` | string | Saved search database identifier |
+| ↳ `id` | number | Saved search database identifier |
| ↳ `description` | string | Saved search description |
### `agiloft_search_records`
diff --git a/apps/docs/content/docs/en/tools/posthog.mdx b/apps/docs/content/docs/en/tools/posthog.mdx
index 61b50e82810..6b471ef60fb 100644
--- a/apps/docs/content/docs/en/tools/posthog.mdx
+++ b/apps/docs/content/docs/en/tools/posthog.mdx
@@ -87,7 +87,7 @@ List persons (users) in PostHog. Returns user profiles with their properties and
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `personalApiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
+| `apiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
| `region` | string | No | PostHog region: us \(default\) or eu |
| `projectId` | string | Yes | PostHog Project ID \(e.g., "12345" or project UUID\) |
| `limit` | number | No | Number of persons to return \(default: 100, max: 100\) |
@@ -115,7 +115,7 @@ Get detailed information about a specific person in PostHog by their ID or UUID.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `personalApiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
+| `apiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
| `region` | string | No | PostHog region: us \(default\) or eu |
| `projectId` | string | Yes | PostHog Project ID \(e.g., "12345" or project UUID\) |
| `personId` | string | Yes | Person ID or UUID to retrieve \(e.g., "01234567-89ab-cdef-0123-456789abcdef"\) |
@@ -139,7 +139,7 @@ Delete a person from PostHog. This will remove all associated events and data. U
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `personalApiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
+| `apiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
| `region` | string | No | PostHog region: us \(default\) or eu |
| `projectId` | string | Yes | PostHog Project ID \(e.g., "12345" or project UUID\) |
| `personId` | string | Yes | Person ID or UUID to delete \(e.g., "01234567-89ab-cdef-0123-456789abcdef"\) |
@@ -158,7 +158,7 @@ Execute a HogQL query in PostHog. HogQL is PostHog
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
-| `personalApiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
+| `apiKey` | string | Yes | PostHog Personal API Key \(for authenticated API access\) |
| `region` | string | No | PostHog region: us \(default\) or eu |
| `projectId` | string | Yes | PostHog Project ID \(e.g., "12345" or project UUID\) |
| `query` | string | Yes | HogQL query to execute. Example: \{"kind": "HogQLQuery", "query": "SELECT event, count\(\) FROM events WHERE timestamp > now\(\) - INTERVAL 1 DAY GROUP BY event"\} |
diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json
index 346cb98feeb..7067930cb24 100644
--- a/apps/sim/app/(landing)/integrations/data/integrations.json
+++ b/apps/sim/app/(landing)/integrations/data/integrations.json
@@ -321,7 +321,7 @@
"name": "Agiloft",
"description": "Manage records in Agiloft CLM",
"longDescription": "Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, and record locking across any table in your knowledge base.",
- "bgColor": "#FFFFFF",
+ "bgColor": "#001028",
"iconName": "AgiloftIcon",
"docsUrl": "https://docs.sim.ai/tools/agiloft",
"operations": [
@@ -372,9 +372,13 @@
{
"name": "Lock Record",
"description": "Lock, unlock, or check the lock status of an Agiloft record."
+ },
+ {
+ "name": "Get Choice Line ID",
+ "description": "Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields."
}
],
- "operationCount": 12,
+ "operationCount": 13,
"triggers": [],
"triggerCount": 0,
"authType": "none",
diff --git a/apps/sim/app/api/tools/agiloft/attach/route.ts b/apps/sim/app/api/tools/agiloft/attach/route.ts
index d0ec62e0fd8..edcbdc4c0f3 100644
--- a/apps/sim/app/api/tools/agiloft/attach/route.ts
+++ b/apps/sim/app/api/tools/agiloft/attach/route.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { agiloftAttachContract } from '@/lib/api/contracts/tools/agiloft'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -90,7 +91,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const agiloftResponse = await fetch(url, {
method: 'PUT',
headers: {
- 'Content-Type': userFile.type || 'application/octet-stream',
+ 'Content-Type': 'application/octet-stream',
Authorization: `Bearer ${token}`,
},
body: new Uint8Array(fileBuffer),
@@ -136,9 +137,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
} catch (error) {
logger.error(`[${requestId}] Error attaching file to Agiloft:`, error)
- return NextResponse.json(
- { success: false, error: error instanceof Error ? error.message : 'Internal server error' },
- { status: 500 }
- )
+ return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
}
})
diff --git a/apps/sim/app/api/tools/agiloft/retrieve/route.ts b/apps/sim/app/api/tools/agiloft/retrieve/route.ts
index 3f94c8bc739..64bd72daae8 100644
--- a/apps/sim/app/api/tools/agiloft/retrieve/route.ts
+++ b/apps/sim/app/api/tools/agiloft/retrieve/route.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { agiloftRetrieveContract } from '@/lib/api/contracts/tools/agiloft'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
@@ -127,9 +128,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
} catch (error) {
logger.error(`[${requestId}] Error retrieving Agiloft attachment:`, error)
- return NextResponse.json(
- { success: false, error: error instanceof Error ? error.message : 'Internal server error' },
- { status: 500 }
- )
+ return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
}
})
diff --git a/apps/sim/blocks/blocks/agiloft.ts b/apps/sim/blocks/blocks/agiloft.ts
index 36e571dad99..ee1791d3048 100644
--- a/apps/sim/blocks/blocks/agiloft.ts
+++ b/apps/sim/blocks/blocks/agiloft.ts
@@ -13,7 +13,7 @@ export const AgiloftBlock: BlockConfig = {
category: 'tools',
integrationType: IntegrationType.Productivity,
tags: ['automation'],
- bgColor: '#FFFFFF',
+ bgColor: '#001028',
icon: AgiloftIcon,
authMode: AuthMode.ApiKey,
@@ -35,6 +35,7 @@ export const AgiloftBlock: BlockConfig = {
{ label: 'Remove Attachment', id: 'remove_attachment' },
{ label: 'Attachment Info', id: 'attachment_info' },
{ label: 'Lock Record', id: 'lock_record' },
+ { label: 'Get Choice Line ID', id: 'get_choice_line_id' },
],
value: () => 'search_records',
},
@@ -44,7 +45,6 @@ export const AgiloftBlock: BlockConfig = {
type: 'short-input',
placeholder: 'https://mycompany.agiloft.com',
required: true,
- password: false,
},
{
id: 'knowledgeBase',
@@ -151,16 +151,36 @@ export const AgiloftBlock: BlockConfig = {
id: 'fieldName',
title: 'Field Name',
type: 'short-input',
- placeholder: 'e.g., attached_docs',
+ placeholder: 'e.g., attached_docs, priority',
condition: {
field: 'operation',
- value: ['attach_file', 'retrieve_attachment', 'remove_attachment', 'attachment_info'],
+ value: [
+ 'attach_file',
+ 'retrieve_attachment',
+ 'remove_attachment',
+ 'attachment_info',
+ 'get_choice_line_id',
+ ],
},
required: {
field: 'operation',
- value: ['attach_file', 'retrieve_attachment', 'remove_attachment', 'attachment_info'],
+ value: [
+ 'attach_file',
+ 'retrieve_attachment',
+ 'remove_attachment',
+ 'attachment_info',
+ 'get_choice_line_id',
+ ],
},
},
+ {
+ id: 'value',
+ title: 'Choice Value',
+ type: 'short-input',
+ placeholder: 'e.g., High, Active',
+ condition: { field: 'operation', value: 'get_choice_line_id' },
+ required: { field: 'operation', value: 'get_choice_line_id' },
+ },
{
id: 'uploadFile',
title: 'File',
@@ -254,6 +274,7 @@ export const AgiloftBlock: BlockConfig = {
'agiloft_attachment_info',
'agiloft_create_record',
'agiloft_delete_record',
+ 'agiloft_get_choice_line_id',
'agiloft_lock_record',
'agiloft_read_record',
'agiloft_remove_attachment',
@@ -288,7 +309,8 @@ export const AgiloftBlock: BlockConfig = {
data: { type: 'string', description: 'Record data as JSON' },
query: { type: 'string', description: 'Search query' },
where: { type: 'string', description: 'SQL WHERE clause for select' },
- fieldName: { type: 'string', description: 'Attachment field name' },
+ fieldName: { type: 'string', description: 'Attachment field name or choice field name' },
+ value: { type: 'string', description: 'Choice value to resolve to its line ID' },
attachFile: { type: 'file', description: 'File to attach' },
fileName: { type: 'string', description: 'Name for the attached file' },
position: { type: 'string', description: 'Attachment position index' },
@@ -403,5 +425,10 @@ export const AgiloftBlock: BlockConfig = {
description: 'Minutes until the lock expires',
condition: { field: 'operation', value: 'lock_record' },
},
+ choiceLineId: {
+ type: 'number',
+ description: 'Internal numeric ID of the resolved choice value',
+ condition: { field: 'operation', value: 'get_choice_line_id' },
+ },
},
}
diff --git a/apps/sim/lib/api/contracts/tools/agiloft.ts b/apps/sim/lib/api/contracts/tools/agiloft.ts
index 38b18657eb9..f8c6e1f565c 100644
--- a/apps/sim/lib/api/contracts/tools/agiloft.ts
+++ b/apps/sim/lib/api/contracts/tools/agiloft.ts
@@ -50,8 +50,8 @@ export const agiloftAttachBodySchema = z.object({
table: z.string().min(1, 'Table is required'),
recordId: z.string().min(1, 'Record ID is required'),
fieldName: z.string().min(1, 'Field name is required'),
- file: FileInputSchema.optional().nullable(),
- fileName: z.string().optional().nullable(),
+ file: FileInputSchema.optional(),
+ fileName: z.string().optional(),
})
export const agiloftRetrieveContract = defineRouteContract({
diff --git a/apps/sim/tools/agiloft/attachment_info.ts b/apps/sim/tools/agiloft/attachment_info.ts
index 38471b74e3b..07986303fe8 100644
--- a/apps/sim/tools/agiloft/attachment_info.ts
+++ b/apps/sim/tools/agiloft/attachment_info.ts
@@ -91,9 +91,13 @@ export const agiloftAttachmentInfoTool: ToolConfig<
for (let i = 0; i < result.length; i++) {
const item = result[i] as Record
attachments.push({
- position: (item.position as number) ?? i,
- name: (item.name as string) ?? (item.filename as string) ?? '',
- size: (item.size as number) ?? 0,
+ position: (item.filePosition as number) ?? (item.position as number) ?? i,
+ name:
+ (item.fileName as string) ??
+ (item.name as string) ??
+ (item.filename as string) ??
+ '',
+ size: (item.size as number) ?? (item.fileSize as number) ?? 0,
})
}
}
diff --git a/apps/sim/tools/agiloft/create_record.ts b/apps/sim/tools/agiloft/create_record.ts
index d89943f9750..f4763f55bad 100644
--- a/apps/sim/tools/agiloft/create_record.ts
+++ b/apps/sim/tools/agiloft/create_record.ts
@@ -72,7 +72,7 @@ export const agiloftCreateRecordTool: ToolConfig ({
url: buildCreateRecordUrl(base, params),
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body,
}),
async (response) => {
diff --git a/apps/sim/tools/agiloft/delete_record.ts b/apps/sim/tools/agiloft/delete_record.ts
index 3796459dd64..42538104961 100644
--- a/apps/sim/tools/agiloft/delete_record.ts
+++ b/apps/sim/tools/agiloft/delete_record.ts
@@ -60,6 +60,7 @@ export const agiloftDeleteRecordTool: ToolConfig ({
url: buildDeleteRecordUrl(base, params),
method: 'DELETE',
+ headers: { Accept: 'application/json' },
}),
async (response) => {
if (!response.ok) {
diff --git a/apps/sim/tools/agiloft/get_choice_line_id.ts b/apps/sim/tools/agiloft/get_choice_line_id.ts
new file mode 100644
index 00000000000..11df1040565
--- /dev/null
+++ b/apps/sim/tools/agiloft/get_choice_line_id.ts
@@ -0,0 +1,130 @@
+import type {
+ AgiloftGetChoiceLineIdParams,
+ AgiloftGetChoiceLineIdResponse,
+} from '@/tools/agiloft/types'
+import { buildGetChoiceLineIdUrl, executeAgiloftRequest } from '@/tools/agiloft/utils'
+import type { ToolConfig } from '@/tools/types'
+
+export const agiloftGetChoiceLineIdTool: ToolConfig<
+ AgiloftGetChoiceLineIdParams,
+ AgiloftGetChoiceLineIdResponse
+> = {
+ id: 'agiloft_get_choice_line_id',
+ name: 'Agiloft Get Choice Line ID',
+ description:
+ 'Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields.',
+ version: '1.0.0',
+
+ params: {
+ instanceUrl: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)',
+ },
+ knowledgeBase: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Knowledge base name',
+ },
+ login: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Agiloft username',
+ },
+ password: {
+ type: 'string',
+ required: true,
+ visibility: 'user-only',
+ description: 'Agiloft password',
+ },
+ table: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Table name (e.g., "case", "contracts")',
+ },
+ fieldName: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Choice field name (e.g., "priority", "status")',
+ },
+ value: {
+ type: 'string',
+ required: true,
+ visibility: 'user-or-llm',
+ description: 'Choice display value to resolve (e.g., "High", "Active")',
+ },
+ },
+
+ request: {
+ url: 'https://placeholder.agiloft.com',
+ method: 'GET',
+ headers: () => ({}),
+ },
+
+ directExecution: async (params) => {
+ return executeAgiloftRequest(
+ params,
+ (base) => ({
+ url: buildGetChoiceLineIdUrl(base, params),
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ }),
+ async (response) => {
+ if (!response.ok) {
+ const errorText = await response.text()
+ return {
+ success: false,
+ output: { choiceLineId: null },
+ error: `Agiloft error: ${response.status} - ${errorText}`,
+ }
+ }
+
+ const data = (await response.json()) as Record
+ const result = data.result ?? data
+ let choiceLineId: number | null = null
+
+ if (typeof result === 'number') {
+ choiceLineId = result
+ } else if (typeof result === 'string') {
+ const parsed = Number(result)
+ choiceLineId = Number.isFinite(parsed) ? parsed : null
+ } else if (typeof result === 'object' && result !== null) {
+ const obj = result as Record
+ const idVal = obj.id ?? obj.choiceLineId ?? obj.lineId
+ if (typeof idVal === 'number') {
+ choiceLineId = idVal
+ } else if (typeof idVal === 'string') {
+ const parsed = Number(idVal)
+ choiceLineId = Number.isFinite(parsed) ? parsed : null
+ }
+ }
+
+ if (choiceLineId === null) {
+ return {
+ success: false,
+ output: { choiceLineId: null },
+ error: `No choice line ID found for value "${params.value}" in field "${params.fieldName}"`,
+ }
+ }
+
+ return {
+ success: data.success !== false,
+ output: { choiceLineId },
+ }
+ }
+ )
+ },
+
+ outputs: {
+ choiceLineId: {
+ type: 'number',
+ description: 'Internal numeric line ID of the choice value',
+ optional: true,
+ },
+ },
+}
diff --git a/apps/sim/tools/agiloft/index.ts b/apps/sim/tools/agiloft/index.ts
index 2ff7ac6f4f0..c47d18f98c2 100644
--- a/apps/sim/tools/agiloft/index.ts
+++ b/apps/sim/tools/agiloft/index.ts
@@ -2,6 +2,7 @@ export { agiloftAttachFileTool } from '@/tools/agiloft/attach_file'
export { agiloftAttachmentInfoTool } from '@/tools/agiloft/attachment_info'
export { agiloftCreateRecordTool } from '@/tools/agiloft/create_record'
export { agiloftDeleteRecordTool } from '@/tools/agiloft/delete_record'
+export { agiloftGetChoiceLineIdTool } from '@/tools/agiloft/get_choice_line_id'
export { agiloftLockRecordTool } from '@/tools/agiloft/lock_record'
export { agiloftReadRecordTool } from '@/tools/agiloft/read_record'
export { agiloftRemoveAttachmentTool } from '@/tools/agiloft/remove_attachment'
diff --git a/apps/sim/tools/agiloft/read_record.ts b/apps/sim/tools/agiloft/read_record.ts
index c9760a70089..70b015c43bf 100644
--- a/apps/sim/tools/agiloft/read_record.ts
+++ b/apps/sim/tools/agiloft/read_record.ts
@@ -65,6 +65,7 @@ export const agiloftReadRecordTool: ToolConfig ({
url: buildReadRecordUrl(base, params),
method: 'GET',
+ headers: { Accept: 'application/json' },
}),
async (response) => {
if (!response.ok) {
diff --git a/apps/sim/tools/agiloft/remove_attachment.ts b/apps/sim/tools/agiloft/remove_attachment.ts
index 3117017719f..7e9a9d6f2d4 100644
--- a/apps/sim/tools/agiloft/remove_attachment.ts
+++ b/apps/sim/tools/agiloft/remove_attachment.ts
@@ -67,7 +67,7 @@ export const agiloftRemoveAttachmentTool: ToolConfig<
request: {
url: 'https://placeholder.agiloft.com',
- method: 'GET',
+ method: 'DELETE',
headers: () => ({}),
},
@@ -76,7 +76,7 @@ export const agiloftRemoveAttachmentTool: ToolConfig<
params,
(base) => ({
url: buildRemoveAttachmentUrl(base, params),
- method: 'GET',
+ method: 'DELETE',
}),
async (response) => {
const text = await response.text()
diff --git a/apps/sim/tools/agiloft/saved_search.ts b/apps/sim/tools/agiloft/saved_search.ts
index 8b28cbd140b..8d645d871d0 100644
--- a/apps/sim/tools/agiloft/saved_search.ts
+++ b/apps/sim/tools/agiloft/saved_search.ts
@@ -107,8 +107,12 @@ export const agiloftSavedSearchTool: ToolConfig<
properties: {
name: { type: 'string', description: 'Saved search name' },
label: { type: 'string', description: 'Saved search display label' },
- id: { type: 'string', description: 'Saved search database identifier' },
- description: { type: 'string', description: 'Saved search description' },
+ id: { type: 'number', description: 'Saved search database identifier' },
+ description: {
+ type: 'string',
+ description: 'Saved search description',
+ optional: true,
+ },
},
},
},
diff --git a/apps/sim/tools/agiloft/search_records.ts b/apps/sim/tools/agiloft/search_records.ts
index 422140a81aa..b05465c0be5 100644
--- a/apps/sim/tools/agiloft/search_records.ts
+++ b/apps/sim/tools/agiloft/search_records.ts
@@ -94,45 +94,44 @@ export const agiloftSearchRecordsTool: ToolConfig<
const data = (await response.json()) as Record
const records: Record[] = []
+ const result = (data.result ?? data) as Record
- if (data.result && Array.isArray(data.result)) {
- for (const item of data.result as Record[]) {
+ if (Array.isArray(result)) {
+ for (const item of result as Record[]) {
records.push(item)
}
- } else if (Array.isArray(data)) {
- for (const item of data as Record[]) {
- records.push(item)
- }
- } else if (data.results && Array.isArray(data.results)) {
- for (const item of data.results as Record[]) {
- records.push(item)
- }
- } else if (data.records && Array.isArray(data.records)) {
- for (const item of data.records as Record[]) {
- records.push(item)
- }
- } else if (typeof data.EWREST_length === 'number') {
- const count = data.EWREST_length as number
- for (let i = 0; i < count; i++) {
- const record: Record = {}
- for (const key of Object.keys(data)) {
- const match = key.match(/^EWREST_(.+)_(\d+)$/)
- if (match && Number(match[2]) === i) {
- record[match[1]] = data[key]
+ } else {
+ const lengthRaw = result.EWREST_length ?? data.EWREST_length
+ const count = typeof lengthRaw === 'string' ? Number(lengthRaw) : (lengthRaw as number)
+ if (typeof count === 'number' && Number.isFinite(count)) {
+ const source = (result.EWREST_length != null ? result : data) as Record
+ for (let i = 0; i < count; i++) {
+ const record: Record = {}
+ for (const key of Object.keys(source)) {
+ const match = key.match(/^EWREST_(.+)_(\d+)$/)
+ if (match && Number(match[2]) === i) {
+ record[match[1]] = source[key]
+ }
+ }
+ if (Object.keys(record).length > 0) {
+ records.push(record)
}
- }
- if (Object.keys(record).length > 0) {
- records.push(record)
}
}
}
- const totalCount =
- (data.totalCount as number) ??
- (data.total as number) ??
- (data.count as number) ??
- (data.EWREST_length as number) ??
+ const totalCountRaw =
+ result.totalCount ??
+ result.total ??
+ result.count ??
+ result.EWREST_length ??
+ data.totalCount ??
+ data.total ??
+ data.count ??
+ data.EWREST_length ??
records.length
+ const totalCount =
+ typeof totalCountRaw === 'string' ? Number(totalCountRaw) : (totalCountRaw as number)
const page = params.page ? Number(params.page) : 0
const limit = params.limit ? Number(params.limit) : 25
diff --git a/apps/sim/tools/agiloft/select_records.ts b/apps/sim/tools/agiloft/select_records.ts
index 521ea497fbd..de4be3139cb 100644
--- a/apps/sim/tools/agiloft/select_records.ts
+++ b/apps/sim/tools/agiloft/select_records.ts
@@ -95,14 +95,22 @@ export const agiloftSelectRecordsTool: ToolConfig<
}
}
- const totalCount =
- data.EWREST_id_length ?? data.totalCount ?? data.total ?? data.count ?? recordIds.length
+ const totalCountRaw =
+ result.EWREST_id_length ??
+ result.totalCount ??
+ result.total ??
+ result.count ??
+ data.EWREST_id_length ??
+ data.totalCount ??
+ data.total ??
+ data.count ??
+ recordIds.length
return {
success: data.success !== false,
output: {
recordIds,
- totalCount: Number(totalCount),
+ totalCount: Number(totalCountRaw),
},
}
}
diff --git a/apps/sim/tools/agiloft/types.ts b/apps/sim/tools/agiloft/types.ts
index 9d132631556..849c6ab05c9 100644
--- a/apps/sim/tools/agiloft/types.ts
+++ b/apps/sim/tools/agiloft/types.ts
@@ -160,3 +160,14 @@ export interface AgiloftRemoveAttachmentResponse extends ToolResponse {
remainingAttachments: number
}
}
+
+export interface AgiloftGetChoiceLineIdParams extends AgiloftBaseParams {
+ fieldName: string
+ value: string
+}
+
+export interface AgiloftGetChoiceLineIdResponse extends ToolResponse {
+ output: {
+ choiceLineId: number | null
+ }
+}
diff --git a/apps/sim/tools/agiloft/update_record.ts b/apps/sim/tools/agiloft/update_record.ts
index 0c3f8a2d096..661be1b3a8a 100644
--- a/apps/sim/tools/agiloft/update_record.ts
+++ b/apps/sim/tools/agiloft/update_record.ts
@@ -78,7 +78,7 @@ export const agiloftUpdateRecordTool: ToolConfig ({
url: buildUpdateRecordUrl(base, params),
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body,
}),
async (response) => {
diff --git a/apps/sim/tools/agiloft/utils.ts b/apps/sim/tools/agiloft/utils.ts
index 252dcb4a819..47184deb5fb 100644
--- a/apps/sim/tools/agiloft/utils.ts
+++ b/apps/sim/tools/agiloft/utils.ts
@@ -4,6 +4,7 @@ import type {
AgiloftAttachmentInfoParams,
AgiloftBaseParams,
AgiloftDeleteRecordParams,
+ AgiloftGetChoiceLineIdParams,
AgiloftLockRecordParams,
AgiloftReadRecordParams,
AgiloftRemoveAttachmentParams,
@@ -243,6 +244,15 @@ export function buildAttachFileUrl(
return `${base}/ewws/EWAttach?$KB=${kb}&$table=${table}&$lang=en&id=${recordId}&field=${fieldName}&fileName=${encodedFileName}`
}
+export function buildGetChoiceLineIdUrl(
+ base: string,
+ params: AgiloftGetChoiceLineIdParams
+): string {
+ const field = encodeURIComponent(params.fieldName.trim())
+ const value = encodeURIComponent(params.value.trim())
+ return `${base}/ewws/EWGetChoiceLineId/.json?${buildEwBaseQuery(params)}&field=${field}&value=${value}`
+}
+
export function getLockHttpMethod(lockAction: string): HttpMethod {
switch (lockAction) {
case 'lock':
diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts
index 9130ac52dee..6bc8feea7e3 100644
--- a/apps/sim/tools/registry.ts
+++ b/apps/sim/tools/registry.ts
@@ -60,6 +60,7 @@ import {
agiloftAttachmentInfoTool,
agiloftCreateRecordTool,
agiloftDeleteRecordTool,
+ agiloftGetChoiceLineIdTool,
agiloftLockRecordTool,
agiloftReadRecordTool,
agiloftRemoveAttachmentTool,
@@ -3003,6 +3004,7 @@ export const tools: Record = {
agiloft_attachment_info: agiloftAttachmentInfoTool,
agiloft_create_record: agiloftCreateRecordTool,
agiloft_delete_record: agiloftDeleteRecordTool,
+ agiloft_get_choice_line_id: agiloftGetChoiceLineIdTool,
agiloft_lock_record: agiloftLockRecordTool,
agiloft_read_record: agiloftReadRecordTool,
agiloft_remove_attachment: agiloftRemoveAttachmentTool,
From bfd0f46119aadfe472df85a508bce6efb2b92a57 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 6 May 2026 12:30:57 -0700
Subject: [PATCH 08/17] improvement(next): bundle and CI cache config (#4478)
- drop redundant turbopack config (Next 16 defaults)
- remove lucide-react/date-fns from optimizePackageImports (built-in defaults)
- enable turbopackFileSystemCacheForBuild for warm CI builds
- disable poweredByHeader
- swap actions/cache for Blacksmith sticky disk on .next/cache
---
.github/workflows/test-build.yml | 8 +++-----
apps/sim/next.config.ts | 9 ++-------
2 files changed, 5 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml
index 68c5a9901de..4359fd261ea 100644
--- a/.github/workflows/test-build.yml
+++ b/.github/workflows/test-build.yml
@@ -44,13 +44,11 @@ jobs:
key: ${{ github.repository }}-turbo-cache
path: ./.turbo
- - name: Restore Next.js build cache
- uses: actions/cache@v5
+ - name: Mount Next.js build cache (Sticky Disk)
+ uses: useblacksmith/stickydisk@v1
with:
+ key: ${{ github.repository }}-nextjs-cache
path: ./apps/sim/.next/cache
- key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }}
- restore-keys: |
- ${{ runner.os }}-nextjs-
- name: Install dependencies
run: bun install --frozen-lockfile
diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts
index 7a96c063cfe..68ad3b37fa7 100644
--- a/apps/sim/next.config.ts
+++ b/apps/sim/next.config.ts
@@ -10,6 +10,7 @@ import {
const nextConfig: NextConfig = {
devIndicators: false,
+ poweredByHeader: false,
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
@@ -75,9 +76,6 @@ const nextConfig: NextConfig = {
ignoreBuildErrors: isTruthy(env.DOCKER_BUILD),
},
output: isTruthy(env.DOCKER_BUILD) ? 'standalone' : undefined,
- turbopack: {
- resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.json'],
- },
serverExternalPackages: [
'@1password/sdk',
'unpdf',
@@ -99,11 +97,9 @@ const nextConfig: NextConfig = {
},
experimental: {
optimizeCss: true,
- turbopackSourceMaps: false,
- turbopackFileSystemCacheForDev: true,
preloadEntriesOnStart: false,
+ turbopackFileSystemCacheForBuild: true,
optimizePackageImports: [
- 'lucide-react',
'lodash',
'framer-motion',
'reactflow',
@@ -119,7 +115,6 @@ const nextConfig: NextConfig = {
'@radix-ui/react-slider',
'streamdown',
'zod',
- 'date-fns',
],
},
...(isDev && {
From 79ffccc14032267efc085ad6cbb0c755eab44097 Mon Sep 17 00:00:00 2001
From: Vikhyath Mondreti
Date: Wed, 6 May 2026 12:44:17 -0700
Subject: [PATCH 09/17] feat(emailbison): block, tools, sharepoint v2 block
with cleaner code (#4470)
* feat(emailbison): block, tools
* type improvments
* typecheck issue
* add email bison trigger, cleanup sharepoint block
* address comments
* fix tests
* error on partial upload failures
---
apps/docs/components/icons.tsx | 11 +
apps/docs/components/ui/icon-mapping.ts | 3 +
.../docs/content/docs/en/tools/emailbison.mdx | 449 +++++++
apps/docs/content/docs/en/tools/meta.json | 1 +
.../docs/content/docs/en/tools/sharepoint.mdx | 28 +-
.../content/docs/en/triggers/emailbison.mdx | 1178 +++++++++++++++++
apps/docs/content/docs/en/triggers/meta.json | 1 +
.../integrations/(shell)/[slug]/page.tsx | 11 +-
.../integrations/data/icon-mapping.ts | 4 +-
.../integrations/data/integrations.json | 79 +-
.../app/api/tools/sharepoint/upload/route.ts | 113 +-
apps/sim/blocks/blocks/emailbison.ts | 627 +++++++++
apps/sim/blocks/blocks/sharepoint.ts | 483 +++++++
apps/sim/blocks/registry.ts | 5 +-
apps/sim/components/icons.tsx | 11 +
apps/sim/lib/webhooks/providers/emailbison.ts | 300 +++++
apps/sim/lib/webhooks/providers/registry.ts | 2 +
.../emailbison/attach_leads_to_campaign.ts | 70 +
.../tools/emailbison/attach_tags_to_leads.ts | 67 +
apps/sim/tools/emailbison/create_campaign.ts | 58 +
apps/sim/tools/emailbison/create_lead.ts | 94 ++
apps/sim/tools/emailbison/create_tag.ts | 42 +
apps/sim/tools/emailbison/get_lead.ts | 45 +
apps/sim/tools/emailbison/index.ts | 14 +
apps/sim/tools/emailbison/list_campaigns.ts | 42 +
apps/sim/tools/emailbison/list_leads.ts | 85 ++
apps/sim/tools/emailbison/list_replies.ts | 108 ++
apps/sim/tools/emailbison/list_tags.ts | 38 +
apps/sim/tools/emailbison/types.ts | 254 ++++
apps/sim/tools/emailbison/update_campaign.ts | 114 ++
.../emailbison/update_campaign_status.ts | 64 +
apps/sim/tools/emailbison/update_lead.ts | 106 ++
apps/sim/tools/emailbison/utils.ts | 482 +++++++
apps/sim/tools/registry.ts | 36 +-
apps/sim/tools/sharepoint/add_list_items.ts | 17 +-
apps/sim/tools/sharepoint/create_list.ts | 46 +-
apps/sim/tools/sharepoint/create_page.ts | 20 +-
apps/sim/tools/sharepoint/get_list.ts | 92 +-
apps/sim/tools/sharepoint/index.ts | 2 +
apps/sim/tools/sharepoint/list_sites.ts | 44 +-
apps/sim/tools/sharepoint/read_page.ts | 92 +-
apps/sim/tools/sharepoint/types.ts | 66 +-
apps/sim/tools/sharepoint/update_list.ts | 19 +-
apps/sim/tools/sharepoint/upload_file.ts | 60 +-
apps/sim/tools/sharepoint/utils.ts | 24 +
.../emailbison/email_account_added.ts | 26 +
.../emailbison/email_account_disconnected.ts | 26 +
.../emailbison/email_account_reconnected.ts | 26 +
.../emailbison/email_account_removed.ts | 26 +
apps/sim/triggers/emailbison/email_bounced.ts | 26 +
apps/sim/triggers/emailbison/email_opened.ts | 26 +
apps/sim/triggers/emailbison/email_sent.ts | 27 +
apps/sim/triggers/emailbison/index.ts | 17 +
.../emailbison/lead_first_contacted.ts | 26 +
.../triggers/emailbison/lead_interested.ts | 26 +
apps/sim/triggers/emailbison/lead_replied.ts | 26 +
.../triggers/emailbison/lead_unsubscribed.ts | 26 +
.../triggers/emailbison/manual_email_sent.ts | 26 +
apps/sim/triggers/emailbison/tag_attached.ts | 26 +
apps/sim/triggers/emailbison/tag_removed.ts | 26 +
.../emailbison/untracked_reply_received.ts | 26 +
apps/sim/triggers/emailbison/utils.ts | 510 +++++++
.../warmup_disabled_causing_bounces.ts | 26 +
.../warmup_disabled_receiving_bounces.ts | 26 +
apps/sim/triggers/registry.ts | 36 +
65 files changed, 6263 insertions(+), 250 deletions(-)
create mode 100644 apps/docs/content/docs/en/tools/emailbison.mdx
create mode 100644 apps/docs/content/docs/en/triggers/emailbison.mdx
create mode 100644 apps/sim/blocks/blocks/emailbison.ts
create mode 100644 apps/sim/lib/webhooks/providers/emailbison.ts
create mode 100644 apps/sim/tools/emailbison/attach_leads_to_campaign.ts
create mode 100644 apps/sim/tools/emailbison/attach_tags_to_leads.ts
create mode 100644 apps/sim/tools/emailbison/create_campaign.ts
create mode 100644 apps/sim/tools/emailbison/create_lead.ts
create mode 100644 apps/sim/tools/emailbison/create_tag.ts
create mode 100644 apps/sim/tools/emailbison/get_lead.ts
create mode 100644 apps/sim/tools/emailbison/index.ts
create mode 100644 apps/sim/tools/emailbison/list_campaigns.ts
create mode 100644 apps/sim/tools/emailbison/list_leads.ts
create mode 100644 apps/sim/tools/emailbison/list_replies.ts
create mode 100644 apps/sim/tools/emailbison/list_tags.ts
create mode 100644 apps/sim/tools/emailbison/types.ts
create mode 100644 apps/sim/tools/emailbison/update_campaign.ts
create mode 100644 apps/sim/tools/emailbison/update_campaign_status.ts
create mode 100644 apps/sim/tools/emailbison/update_lead.ts
create mode 100644 apps/sim/tools/emailbison/utils.ts
create mode 100644 apps/sim/triggers/emailbison/email_account_added.ts
create mode 100644 apps/sim/triggers/emailbison/email_account_disconnected.ts
create mode 100644 apps/sim/triggers/emailbison/email_account_reconnected.ts
create mode 100644 apps/sim/triggers/emailbison/email_account_removed.ts
create mode 100644 apps/sim/triggers/emailbison/email_bounced.ts
create mode 100644 apps/sim/triggers/emailbison/email_opened.ts
create mode 100644 apps/sim/triggers/emailbison/email_sent.ts
create mode 100644 apps/sim/triggers/emailbison/index.ts
create mode 100644 apps/sim/triggers/emailbison/lead_first_contacted.ts
create mode 100644 apps/sim/triggers/emailbison/lead_interested.ts
create mode 100644 apps/sim/triggers/emailbison/lead_replied.ts
create mode 100644 apps/sim/triggers/emailbison/lead_unsubscribed.ts
create mode 100644 apps/sim/triggers/emailbison/manual_email_sent.ts
create mode 100644 apps/sim/triggers/emailbison/tag_attached.ts
create mode 100644 apps/sim/triggers/emailbison/tag_removed.ts
create mode 100644 apps/sim/triggers/emailbison/untracked_reply_received.ts
create mode 100644 apps/sim/triggers/emailbison/utils.ts
create mode 100644 apps/sim/triggers/emailbison/warmup_disabled_causing_bounces.ts
create mode 100644 apps/sim/triggers/emailbison/warmup_disabled_receiving_bounces.ts
diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx
index dae53828ccb..c4bc260742b 100644
--- a/apps/docs/components/icons.tsx
+++ b/apps/docs/components/icons.tsx
@@ -415,6 +415,17 @@ export function MailIcon(props: SVGProps) {
)
}
+export function EmailBisonIcon(props: SVGProps) {
+ return (
+
+ )
+}
+
export function MailServerIcon(props: SVGProps) {
return (
`,
+ innerHtml: `${pageContent.replace(/"/g, '"').replace(/'/g, ''')}
`,
},
],
},
diff --git a/apps/sim/tools/sharepoint/get_list.ts b/apps/sim/tools/sharepoint/get_list.ts
index f5528ee95b6..bcf3036a977 100644
--- a/apps/sim/tools/sharepoint/get_list.ts
+++ b/apps/sim/tools/sharepoint/get_list.ts
@@ -4,6 +4,7 @@ import type {
SharepointList,
SharepointToolParams,
} from '@/tools/sharepoint/types'
+import { assertGraphNextPageUrl, getGraphNextPageUrl, optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SharePointGetList')
@@ -12,7 +13,7 @@ export const getListTool: ToolConfig {
- const siteId = params.siteId || params.siteSelector || 'root'
+ if (params.nextPageUrl) {
+ return assertGraphNextPageUrl(params.nextPageUrl)
+ }
+
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ const listId = optionalTrim(params.listId)
- // If neither listId nor listTitle provided, list all lists in the site
- if (!params.listId) {
+ if (!listId) {
const baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/lists`
const url = new URL(baseUrl)
const finalUrl = url.toString()
@@ -63,11 +86,9 @@ export const getListTool: ToolConfig {
+ transformResponse: async (response: Response, params) => {
const data = await response.json()
// If the response is a collection of items (from the items endpoint)
@@ -122,25 +142,18 @@ export const getListTool: ToolConfig,
}))
- const nextLink: string | undefined = (data as any)['@odata.nextLink']
- const nextPageToken = nextLink
- ? (() => {
- try {
- const u = new URL(nextLink)
- return u.searchParams.get('$skiptoken') || u.searchParams.get('$skip') || undefined
- } catch {
- return undefined
- }
- })()
- : undefined
+ const nextPageUrl = getGraphNextPageUrl(data as Record)
return {
success: true,
- output: { list: { items } as SharepointList, nextPageToken },
+ output: {
+ list: { id: optionalTrim(params?.listId) || '', items } as SharepointList,
+ items,
+ nextPageUrl,
+ },
}
}
- // If this is a collection of lists (site-level)
if (Array.isArray((data as any).value)) {
const lists: SharepointList[] = (data as any).value.map((l: any) => ({
id: l.id,
@@ -152,25 +165,14 @@ export const getListTool: ToolConfig {
- try {
- const u = new URL(nextLink)
- return u.searchParams.get('$skiptoken') || u.searchParams.get('$skip') || undefined
- } catch {
- return undefined
- }
- })()
- : undefined
+ const nextPageUrl = getGraphNextPageUrl(data as Record)
return {
success: true,
- output: { lists, nextPageToken },
+ output: { lists, nextPageUrl },
}
}
- // Single list response (with optional expands)
const list: SharepointList = {
id: data.id,
displayName: data.displayName ?? data.name,
@@ -242,5 +244,21 @@ export const getListTool: ToolConfig = {
id: 'sharepoint_list_sites',
name: 'List SharePoint Sites',
description: 'List details of all SharePoint sites',
- version: '1.0',
+ version: '1.0.0',
oauth: {
required: true,
@@ -29,6 +30,12 @@ export const listSitesTool: ToolConfig {
+ if (params.nextPageUrl) {
+ return assertGraphNextPageUrl(params.nextPageUrl)
+ }
+
let baseUrl: string
+ const groupId = optionalTrim(params.groupId)
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector)
- if (params.groupId) {
- // Access group team site
- baseUrl = `https://graph.microsoft.com/v1.0/groups/${params.groupId}/sites/root`
- } else if (params.siteId || params.siteSelector) {
- // Access specific site by ID
- const siteId = params.siteId || params.siteSelector
+ if (groupId) {
+ baseUrl = `https://graph.microsoft.com/v1.0/groups/${groupId}/sites/root`
+ } else if (siteId) {
baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}`
} else {
- // get all sites
baseUrl = 'https://graph.microsoft.com/v1.0/sites?search=*'
}
const url = new URL(baseUrl)
- // Use Microsoft Graph $select parameter to get site details
url.searchParams.append(
'$select',
'id,name,displayName,webUrl,description,createdDateTime,lastModifiedDateTime,isPersonalSite,root,siteCollection'
@@ -71,12 +85,10 @@ export const listSitesTool: ToolConfig {
+ transformResponse: async (response: Response) => {
const data = await response.json()
- // Check if this is a search result (multiple sites) or single site
if (data.value && Array.isArray(data.value)) {
- // Multiple sites from search
return {
success: true,
output: {
@@ -89,10 +101,11 @@ export const listSitesTool: ToolConfig),
},
}
}
- // Single site response
+
return {
success: true,
output: {
@@ -155,5 +168,10 @@ export const listSitesTool: ToolConfig {
- // Use specific site if provided, otherwise use root site
- const siteId = params.siteId || params.siteSelector || 'root'
+ if (params.nextPageUrl) {
+ return assertGraphNextPageUrl(params.nextPageUrl)
+ }
+
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ const pageId = optionalTrim(params.pageId)
let baseUrl: string
- if (params.pageId) {
- // Read specific page by ID
- baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${params.pageId}`
+ if (pageId) {
+ baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/${pageId}/microsoft.graph.sitePage`
} else {
- // List all pages (with optional filtering by name)
- baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages`
+ baseUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/pages/microsoft.graph.sitePage`
}
const url = new URL(baseUrl)
- // Use Microsoft Graph $select parameter to get page details
- // Only include valid properties for SharePoint pages
url.searchParams.append(
'$select',
- 'id,name,title,webUrl,pageLayout,createdDateTime,lastModifiedDateTime'
+ 'id,name,title,webUrl,pageLayout,description,createdDateTime,lastModifiedDateTime'
)
- // If searching by name, add filter
- if (params.pageName && !params.pageId) {
- // Try to handle both with and without .aspx extension
- const pageName = params.pageName
+ if (params.pageName && !pageId) {
+ const pageName = params.pageName.trim()
const pageNameWithAspx = pageName.endsWith('.aspx') ? pageName : `${pageName}.aspx`
-
- // Search for exact match first, then with .aspx if needed
- url.searchParams.append('$filter', `name eq '${pageName}' or name eq '${pageNameWithAspx}'`)
- url.searchParams.append('$top', '10') // Get more results to find matches
- } else if (!params.pageId && !params.pageName) {
- // When listing all pages, apply maxPages limit
- const maxPages = Math.min(params.maxPages || 10, 50) // Default 10, max 50
+ const escapedPageName = escapeODataString(pageName)
+ const escapedPageNameWithAspx = escapeODataString(pageNameWithAspx)
+
+ url.searchParams.append(
+ '$filter',
+ `name eq '${escapedPageName}' or name eq '${escapedPageNameWithAspx}'`
+ )
+ url.searchParams.append('$top', '10')
+ } else if (!pageId && !params.pageName) {
+ const requestedMaxPages =
+ typeof params.maxPages === 'number' ? params.maxPages : Number(params.maxPages || 10)
+ const maxPages = Math.min(Number.isFinite(requestedMaxPages) ? requestedMaxPages : 10, 50)
url.searchParams.append('$top', maxPages.toString())
}
- // Only expand content when getting a specific page by ID
- if (params.pageId) {
+ if (pageId) {
url.searchParams.append('$expand', 'canvasLayout')
}
@@ -112,7 +127,7 @@ export const readPageTool: ToolConfig
+ nextPageUrl?: string
}
}
@@ -155,12 +158,12 @@ export interface SharepointToolParams {
siteSelector?: string
pageId?: string
pageName?: string
- pageContent?: string
+ pageContent?: string | unknown[] | { columns?: unknown[] }
pageTitle?: string
publishingState?: string
query?: string
pageSize?: number
- pageToken?: string
+ nextPageUrl?: string
hostname?: string
serverRelativePath?: string
groupId?: string
@@ -188,6 +191,7 @@ export interface GraphApiResponse {
id?: string
name?: string
title?: string
+ description?: string | null
webUrl?: string
pageLayout?: string
createdDateTime?: string
@@ -203,6 +207,7 @@ export interface GraphApiPageItem {
id: string
name: string
title?: string
+ description?: string | null
webUrl?: string
pageLayout?: string
createdDateTime?: string
@@ -227,36 +232,6 @@ export interface CanvasLayout {
}>
}
-export interface SharepointReadSiteResponse extends ToolResponse {
- output: {
- site?: {
- id: string
- name: string
- displayName: string
- webUrl: string
- description?: string
- createdDateTime?: string
- lastModifiedDateTime?: string
- isPersonalSite?: boolean
- root?: {
- serverRelativeUrl: string
- }
- siteCollection?: {
- hostname: string
- }
- }
- sites?: Array<{
- id: string
- name: string
- displayName: string
- webUrl: string
- description?: string
- createdDateTime?: string
- lastModifiedDateTime?: string
- }>
- }
-}
-
export type SharepointResponse =
| SharepointListSitesResponse
| SharepointCreatePageResponse
@@ -272,7 +247,8 @@ export interface SharepointGetListResponse extends ToolResponse {
output: {
list?: SharepointList
lists?: SharepointList[]
- nextPageToken?: string
+ items?: SharepointListItem[]
+ nextPageUrl?: string
}
}
@@ -309,9 +285,25 @@ export interface SharepointUploadedFile {
lastModifiedDateTime?: string
}
+export interface SharepointSkippedFile {
+ name: string
+ size: number
+ limit: number
+ reason: string
+}
+
+export interface SharepointUploadError {
+ name: string
+ error: string
+ status?: number
+}
+
export interface SharepointUploadFileResponse extends ToolResponse {
output: {
uploadedFiles: SharepointUploadedFile[]
fileCount: number
+ skippedFiles?: SharepointSkippedFile[]
+ skippedCount?: number
+ errors?: SharepointUploadError[]
}
}
diff --git a/apps/sim/tools/sharepoint/update_list.ts b/apps/sim/tools/sharepoint/update_list.ts
index d2a62227963..a0511e45f2b 100644
--- a/apps/sim/tools/sharepoint/update_list.ts
+++ b/apps/sim/tools/sharepoint/update_list.ts
@@ -3,6 +3,7 @@ import type {
SharepointToolParams,
SharepointUpdateListItemResponse,
} from '@/tools/sharepoint/types'
+import { optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SharePointUpdateListItem')
@@ -14,7 +15,7 @@ export const updateListItemTool: ToolConfig<
id: 'sharepoint_update_list',
name: 'Update SharePoint List Item',
description: 'Update the properties (fields) on a SharePoint list item',
- version: '1.0',
+ version: '1.0.0',
oauth: {
required: true,
@@ -42,7 +43,7 @@ export const updateListItemTool: ToolConfig<
},
listId: {
type: 'string',
- required: false,
+ required: true,
visibility: 'user-or-llm',
description:
'The ID of the list containing the item. Example: b!abc123def456 or a GUID like 12345678-1234-1234-1234-123456789012',
@@ -54,7 +55,7 @@ export const updateListItemTool: ToolConfig<
description: 'The ID of the list item to update. Example: 1, 42, or 123',
},
listItemFields: {
- type: 'object',
+ type: 'json',
required: true,
visibility: 'user-only',
description: 'Field values to update on the list item',
@@ -63,13 +64,15 @@ export const updateListItemTool: ToolConfig<
request: {
url: (params) => {
- const siteId = params.siteId || params.siteSelector || 'root'
- if (!params.itemId) throw new Error('itemId is required')
- if (!params.listId) {
+ const siteId = optionalTrim(params.siteId) || optionalTrim(params.siteSelector) || 'root'
+ const itemId = optionalTrim(params.itemId)
+ const listId = optionalTrim(params.listId)
+ if (!itemId) throw new Error('itemId is required')
+ if (!listId) {
throw new Error('listId must be provided')
}
- const listSegment = params.listId
- return `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listSegment}/items/${params.itemId}/fields`
+ const listSegment = encodeURIComponent(listId)
+ return `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listSegment}/items/${encodeURIComponent(itemId)}/fields`
},
method: 'PATCH',
headers: (params) => ({
diff --git a/apps/sim/tools/sharepoint/upload_file.ts b/apps/sim/tools/sharepoint/upload_file.ts
index 8728e4ea673..94626a82ee6 100644
--- a/apps/sim/tools/sharepoint/upload_file.ts
+++ b/apps/sim/tools/sharepoint/upload_file.ts
@@ -1,11 +1,12 @@
import type { SharepointToolParams, SharepointUploadFileResponse } from '@/tools/sharepoint/types'
+import { optionalTrim } from '@/tools/sharepoint/utils'
import type { ToolConfig } from '@/tools/types'
export const uploadFileTool: ToolConfig = {
id: 'sharepoint_upload_file',
name: 'Upload File to SharePoint',
description: 'Upload files to a SharePoint document library',
- version: '1.0',
+ version: '1.0.0',
oauth: {
required: true,
@@ -47,7 +48,7 @@ export const uploadFileTool: ToolConfig {
return {
accessToken: params.accessToken,
- siteId: params.siteId || 'root',
- driveId: params.driveId || null,
- folderPath: params.folderPath || null,
- fileName: params.fileName || null,
+ siteId: optionalTrim(params.siteId) || 'root',
+ driveId: optionalTrim(params.driveId) || null,
+ folderPath: optionalTrim(params.folderPath) || null,
+ fileName: optionalTrim(params.fileName) || null,
files: params.files || null,
}
},
@@ -73,15 +74,17 @@ export const uploadFileTool: ToolConfig {
const data = await response.json()
- if (!data.success) {
- throw new Error(data.error || 'Failed to upload files to SharePoint')
- }
+ const output = data.output ?? {}
return {
- success: true,
+ success: Boolean(data.success),
output: {
- uploadedFiles: data.output.uploadedFiles,
- fileCount: data.output.fileCount,
+ uploadedFiles: output.uploadedFiles ?? [],
+ fileCount: output.fileCount ?? 0,
+ skippedFiles: output.skippedFiles ?? [],
+ skippedCount: output.skippedCount ?? 0,
+ errors: output.errors ?? [],
},
+ error: data.success ? undefined : data.error || 'Failed to upload files to SharePoint',
}
},
@@ -105,5 +108,38 @@ export const uploadFileTool: ToolConfig)['@odata.nextLink']
+ return typeof nextLink === 'string' ? nextLink : undefined
+}
+
+export function assertGraphNextPageUrl(nextPageUrl: string): string {
+ const trimmed = nextPageUrl.trim()
+ const url = new URL(trimmed)
+ if (url.origin !== 'https://graph.microsoft.com') {
+ throw new Error('nextPageUrl must be a Microsoft Graph @odata.nextLink URL')
+ }
+ return url.toString()
+}
+
function stripHtmlTags(html: string): string {
let text = html
let previous: string
diff --git a/apps/sim/triggers/emailbison/email_account_added.ts b/apps/sim/triggers/emailbison/email_account_added.ts
new file mode 100644
index 00000000000..24669f31bcc
--- /dev/null
+++ b/apps/sim/triggers/emailbison/email_account_added.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonEmailAccountAddedOutputs,
+ buildEmailBisonExtraFields,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonEmailAccountAddedTrigger: TriggerConfig = {
+ id: 'emailbison_email_account_added',
+ name: 'Email Bison Email Account Added',
+ provider: 'emailbison',
+ description: 'Trigger when a sender email account is added to Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_email_account_added',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Email Account Added'),
+ extraFields: buildEmailBisonExtraFields('emailbison_email_account_added'),
+ }),
+ outputs: buildEmailBisonEmailAccountAddedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/email_account_disconnected.ts b/apps/sim/triggers/emailbison/email_account_disconnected.ts
new file mode 100644
index 00000000000..ea70ad995d7
--- /dev/null
+++ b/apps/sim/triggers/emailbison/email_account_disconnected.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonEmailAccountDisconnectedOutputs,
+ buildEmailBisonExtraFields,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonEmailAccountDisconnectedTrigger: TriggerConfig = {
+ id: 'emailbison_email_account_disconnected',
+ name: 'Email Bison Email Account Disconnected',
+ provider: 'emailbison',
+ description: 'Trigger when a sender email account disconnects in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_email_account_disconnected',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Email Account Disconnected'),
+ extraFields: buildEmailBisonExtraFields('emailbison_email_account_disconnected'),
+ }),
+ outputs: buildEmailBisonEmailAccountDisconnectedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/email_account_reconnected.ts b/apps/sim/triggers/emailbison/email_account_reconnected.ts
new file mode 100644
index 00000000000..3b05d36205c
--- /dev/null
+++ b/apps/sim/triggers/emailbison/email_account_reconnected.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonEmailAccountReconnectedOutputs,
+ buildEmailBisonExtraFields,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonEmailAccountReconnectedTrigger: TriggerConfig = {
+ id: 'emailbison_email_account_reconnected',
+ name: 'Email Bison Email Account Reconnected',
+ provider: 'emailbison',
+ description: 'Trigger when a sender email account reconnects in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_email_account_reconnected',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Email Account Reconnected'),
+ extraFields: buildEmailBisonExtraFields('emailbison_email_account_reconnected'),
+ }),
+ outputs: buildEmailBisonEmailAccountReconnectedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/email_account_removed.ts b/apps/sim/triggers/emailbison/email_account_removed.ts
new file mode 100644
index 00000000000..6ea2038c287
--- /dev/null
+++ b/apps/sim/triggers/emailbison/email_account_removed.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonEmailAccountRemovedOutputs,
+ buildEmailBisonExtraFields,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonEmailAccountRemovedTrigger: TriggerConfig = {
+ id: 'emailbison_email_account_removed',
+ name: 'Email Bison Email Account Removed',
+ provider: 'emailbison',
+ description: 'Trigger when a sender email account is removed from Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_email_account_removed',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Email Account Removed'),
+ extraFields: buildEmailBisonExtraFields('emailbison_email_account_removed'),
+ }),
+ outputs: buildEmailBisonEmailAccountRemovedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/email_bounced.ts b/apps/sim/triggers/emailbison/email_bounced.ts
new file mode 100644
index 00000000000..adfee951386
--- /dev/null
+++ b/apps/sim/triggers/emailbison/email_bounced.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonEmailBouncedOutputs,
+ buildEmailBisonExtraFields,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonEmailBouncedTrigger: TriggerConfig = {
+ id: 'emailbison_email_bounced',
+ name: 'Email Bison Email Bounced',
+ provider: 'emailbison',
+ description: 'Trigger when an Email Bison campaign email bounces',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_email_bounced',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Email Bounced'),
+ extraFields: buildEmailBisonExtraFields('emailbison_email_bounced'),
+ }),
+ outputs: buildEmailBisonEmailBouncedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/email_opened.ts b/apps/sim/triggers/emailbison/email_opened.ts
new file mode 100644
index 00000000000..53e7bc9e5f6
--- /dev/null
+++ b/apps/sim/triggers/emailbison/email_opened.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonEmailOpenedOutputs,
+ buildEmailBisonExtraFields,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonEmailOpenedTrigger: TriggerConfig = {
+ id: 'emailbison_email_opened',
+ name: 'Email Bison Email Opened',
+ provider: 'emailbison',
+ description: 'Trigger when an Email Bison campaign email is opened',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_email_opened',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Email Opened'),
+ extraFields: buildEmailBisonExtraFields('emailbison_email_opened'),
+ }),
+ outputs: buildEmailBisonEmailOpenedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/email_sent.ts b/apps/sim/triggers/emailbison/email_sent.ts
new file mode 100644
index 00000000000..3b7bcbabfd4
--- /dev/null
+++ b/apps/sim/triggers/emailbison/email_sent.ts
@@ -0,0 +1,27 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonEmailSentOutputs,
+ buildEmailBisonExtraFields,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonEmailSentTrigger: TriggerConfig = {
+ id: 'emailbison_email_sent',
+ name: 'Email Bison Email Sent',
+ provider: 'emailbison',
+ description: 'Trigger when a campaign email is sent in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_email_sent',
+ triggerOptions: emailBisonTriggerOptions,
+ includeDropdown: true,
+ setupInstructions: emailBisonSetupInstructions('Email Sent'),
+ extraFields: buildEmailBisonExtraFields('emailbison_email_sent'),
+ }),
+ outputs: buildEmailBisonEmailSentOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/index.ts b/apps/sim/triggers/emailbison/index.ts
new file mode 100644
index 00000000000..7876e0731c6
--- /dev/null
+++ b/apps/sim/triggers/emailbison/index.ts
@@ -0,0 +1,17 @@
+export { emailBisonEmailAccountAddedTrigger } from '@/triggers/emailbison/email_account_added'
+export { emailBisonEmailAccountDisconnectedTrigger } from '@/triggers/emailbison/email_account_disconnected'
+export { emailBisonEmailAccountReconnectedTrigger } from '@/triggers/emailbison/email_account_reconnected'
+export { emailBisonEmailAccountRemovedTrigger } from '@/triggers/emailbison/email_account_removed'
+export { emailBisonEmailBouncedTrigger } from '@/triggers/emailbison/email_bounced'
+export { emailBisonEmailOpenedTrigger } from '@/triggers/emailbison/email_opened'
+export { emailBisonEmailSentTrigger } from '@/triggers/emailbison/email_sent'
+export { emailBisonLeadFirstContactedTrigger } from '@/triggers/emailbison/lead_first_contacted'
+export { emailBisonLeadInterestedTrigger } from '@/triggers/emailbison/lead_interested'
+export { emailBisonLeadRepliedTrigger } from '@/triggers/emailbison/lead_replied'
+export { emailBisonLeadUnsubscribedTrigger } from '@/triggers/emailbison/lead_unsubscribed'
+export { emailBisonManualEmailSentTrigger } from '@/triggers/emailbison/manual_email_sent'
+export { emailBisonTagAttachedTrigger } from '@/triggers/emailbison/tag_attached'
+export { emailBisonTagRemovedTrigger } from '@/triggers/emailbison/tag_removed'
+export { emailBisonUntrackedReplyReceivedTrigger } from '@/triggers/emailbison/untracked_reply_received'
+export { emailBisonWarmupDisabledCausingBouncesTrigger } from '@/triggers/emailbison/warmup_disabled_causing_bounces'
+export { emailBisonWarmupDisabledReceivingBouncesTrigger } from '@/triggers/emailbison/warmup_disabled_receiving_bounces'
diff --git a/apps/sim/triggers/emailbison/lead_first_contacted.ts b/apps/sim/triggers/emailbison/lead_first_contacted.ts
new file mode 100644
index 00000000000..406940ce0b0
--- /dev/null
+++ b/apps/sim/triggers/emailbison/lead_first_contacted.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonLeadFirstContactedOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonLeadFirstContactedTrigger: TriggerConfig = {
+ id: 'emailbison_lead_first_contacted',
+ name: 'Email Bison Contact First Emailed',
+ provider: 'emailbison',
+ description: 'Trigger when a contact receives their first campaign email in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_lead_first_contacted',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Contact First Emailed'),
+ extraFields: buildEmailBisonExtraFields('emailbison_lead_first_contacted'),
+ }),
+ outputs: buildEmailBisonLeadFirstContactedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/lead_interested.ts b/apps/sim/triggers/emailbison/lead_interested.ts
new file mode 100644
index 00000000000..b4fbdbd5202
--- /dev/null
+++ b/apps/sim/triggers/emailbison/lead_interested.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonLeadInterestedOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonLeadInterestedTrigger: TriggerConfig = {
+ id: 'emailbison_lead_interested',
+ name: 'Email Bison Contact Interested',
+ provider: 'emailbison',
+ description: 'Trigger when a reply is marked interested in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_lead_interested',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Contact Interested'),
+ extraFields: buildEmailBisonExtraFields('emailbison_lead_interested'),
+ }),
+ outputs: buildEmailBisonLeadInterestedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/lead_replied.ts b/apps/sim/triggers/emailbison/lead_replied.ts
new file mode 100644
index 00000000000..6b47967e42a
--- /dev/null
+++ b/apps/sim/triggers/emailbison/lead_replied.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonLeadRepliedOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonLeadRepliedTrigger: TriggerConfig = {
+ id: 'emailbison_lead_replied',
+ name: 'Email Bison Contact Replied',
+ provider: 'emailbison',
+ description: 'Trigger when a campaign lead replies in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_lead_replied',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Contact Replied'),
+ extraFields: buildEmailBisonExtraFields('emailbison_lead_replied'),
+ }),
+ outputs: buildEmailBisonLeadRepliedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/lead_unsubscribed.ts b/apps/sim/triggers/emailbison/lead_unsubscribed.ts
new file mode 100644
index 00000000000..ce7f5f7aef4
--- /dev/null
+++ b/apps/sim/triggers/emailbison/lead_unsubscribed.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonLeadUnsubscribedOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonLeadUnsubscribedTrigger: TriggerConfig = {
+ id: 'emailbison_lead_unsubscribed',
+ name: 'Email Bison Contact Unsubscribed',
+ provider: 'emailbison',
+ description: 'Trigger when a contact unsubscribes in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_lead_unsubscribed',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Contact Unsubscribed'),
+ extraFields: buildEmailBisonExtraFields('emailbison_lead_unsubscribed'),
+ }),
+ outputs: buildEmailBisonLeadUnsubscribedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/manual_email_sent.ts b/apps/sim/triggers/emailbison/manual_email_sent.ts
new file mode 100644
index 00000000000..772b88e5c06
--- /dev/null
+++ b/apps/sim/triggers/emailbison/manual_email_sent.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonManualEmailSentOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonManualEmailSentTrigger: TriggerConfig = {
+ id: 'emailbison_manual_email_sent',
+ name: 'Email Bison Manual Email Sent',
+ provider: 'emailbison',
+ description: 'Trigger when a manual email is sent in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_manual_email_sent',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Manual Email Sent'),
+ extraFields: buildEmailBisonExtraFields('emailbison_manual_email_sent'),
+ }),
+ outputs: buildEmailBisonManualEmailSentOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/tag_attached.ts b/apps/sim/triggers/emailbison/tag_attached.ts
new file mode 100644
index 00000000000..6d02e97f107
--- /dev/null
+++ b/apps/sim/triggers/emailbison/tag_attached.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonTagAttachedOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonTagAttachedTrigger: TriggerConfig = {
+ id: 'emailbison_tag_attached',
+ name: 'Email Bison Tag Attached',
+ provider: 'emailbison',
+ description: 'Trigger when a custom tag is attached to a taggable in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_tag_attached',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Tag Attached'),
+ extraFields: buildEmailBisonExtraFields('emailbison_tag_attached'),
+ }),
+ outputs: buildEmailBisonTagAttachedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/tag_removed.ts b/apps/sim/triggers/emailbison/tag_removed.ts
new file mode 100644
index 00000000000..cd128f8ec09
--- /dev/null
+++ b/apps/sim/triggers/emailbison/tag_removed.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonTagRemovedOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonTagRemovedTrigger: TriggerConfig = {
+ id: 'emailbison_tag_removed',
+ name: 'Email Bison Tag Removed',
+ provider: 'emailbison',
+ description: 'Trigger when a custom tag is removed from a taggable in Email Bison',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_tag_removed',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Tag Removed'),
+ extraFields: buildEmailBisonExtraFields('emailbison_tag_removed'),
+ }),
+ outputs: buildEmailBisonTagRemovedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/untracked_reply_received.ts b/apps/sim/triggers/emailbison/untracked_reply_received.ts
new file mode 100644
index 00000000000..6dbe61bd0ba
--- /dev/null
+++ b/apps/sim/triggers/emailbison/untracked_reply_received.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonUntrackedReplyReceivedOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonUntrackedReplyReceivedTrigger: TriggerConfig = {
+ id: 'emailbison_untracked_reply_received',
+ name: 'Email Bison Untracked Reply Received',
+ provider: 'emailbison',
+ description: 'Trigger when Email Bison receives a reply not tied to a scheduled campaign email',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_untracked_reply_received',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Untracked Reply Received'),
+ extraFields: buildEmailBisonExtraFields('emailbison_untracked_reply_received'),
+ }),
+ outputs: buildEmailBisonUntrackedReplyReceivedOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/utils.ts b/apps/sim/triggers/emailbison/utils.ts
new file mode 100644
index 00000000000..12d9ebbe789
--- /dev/null
+++ b/apps/sim/triggers/emailbison/utils.ts
@@ -0,0 +1,510 @@
+import type { SubBlockConfig } from '@/blocks/types'
+import type { TriggerOutput } from '@/triggers/types'
+
+export const EMAILBISON_TRIGGER_TO_EVENT_TYPE = {
+ emailbison_email_sent: 'email_sent',
+ emailbison_lead_first_contacted: 'lead_first_contacted',
+ emailbison_lead_replied: 'lead_replied',
+ emailbison_lead_interested: 'lead_interested',
+ emailbison_lead_unsubscribed: 'lead_unsubscribed',
+ emailbison_untracked_reply_received: 'untracked_reply_received',
+ emailbison_email_opened: 'email_opened',
+ emailbison_email_bounced: 'email_bounced',
+ emailbison_email_account_added: 'email_account_added',
+ emailbison_email_account_removed: 'email_account_removed',
+ emailbison_email_account_disconnected: 'email_account_disconnected',
+ emailbison_email_account_reconnected: 'email_account_reconnected',
+ emailbison_manual_email_sent: 'manual_email_sent',
+ emailbison_tag_attached: 'tag_attached',
+ emailbison_tag_removed: 'tag_removed',
+ emailbison_warmup_disabled_receiving_bounces: 'warmup_disabled_receiving_bounces',
+ emailbison_warmup_disabled_causing_bounces: 'warmup_disabled_causing_bounces',
+} as const
+
+export const emailBisonTriggerOptions = [
+ { label: 'Email Sent', id: 'emailbison_email_sent' },
+ { label: 'Contact First Emailed', id: 'emailbison_lead_first_contacted' },
+ { label: 'Contact Replied', id: 'emailbison_lead_replied' },
+ { label: 'Contact Interested', id: 'emailbison_lead_interested' },
+ { label: 'Contact Unsubscribed', id: 'emailbison_lead_unsubscribed' },
+ { label: 'Untracked Reply Received', id: 'emailbison_untracked_reply_received' },
+ { label: 'Email Opened', id: 'emailbison_email_opened' },
+ { label: 'Email Bounced', id: 'emailbison_email_bounced' },
+ { label: 'Email Account Added', id: 'emailbison_email_account_added' },
+ { label: 'Email Account Removed', id: 'emailbison_email_account_removed' },
+ { label: 'Email Account Disconnected', id: 'emailbison_email_account_disconnected' },
+ { label: 'Email Account Reconnected', id: 'emailbison_email_account_reconnected' },
+ { label: 'Manual Email Sent', id: 'emailbison_manual_email_sent' },
+ { label: 'Tag Attached', id: 'emailbison_tag_attached' },
+ { label: 'Tag Removed', id: 'emailbison_tag_removed' },
+ {
+ label: 'Warmup Disabled Receiving Bounces',
+ id: 'emailbison_warmup_disabled_receiving_bounces',
+ },
+ {
+ label: 'Warmup Disabled Causing Bounces',
+ id: 'emailbison_warmup_disabled_causing_bounces',
+ },
+]
+
+export function emailBisonSetupInstructions(eventType: string): string {
+ const instructions = [
+ 'Create an Email Bison API token in Settings > Developer API.',
+ 'Enter the Instance URL from Email Bison’s webhook payload, Full API Reference, or exported Postman collection.',
+ `Click Save Configuration to automatically create an Email Bison webhook for ${eventType}.`,
+ 'The webhook will be automatically deleted from Email Bison when this trigger is removed.',
+ ]
+
+ return instructions
+ .map(
+ (instruction, index) =>
+ `${index + 1}. ${instruction}
`
+ )
+ .join('')
+}
+
+export function buildEmailBisonExtraFields(triggerId: string): SubBlockConfig[] {
+ return [
+ {
+ id: 'apiKey',
+ title: 'API Key',
+ type: 'short-input',
+ placeholder: 'Enter your Email Bison API token',
+ password: true,
+ required: true,
+ paramVisibility: 'user-only',
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ },
+ {
+ id: 'apiBaseUrl',
+ title: 'Instance URL',
+ type: 'short-input',
+ placeholder: 'https://your-emailbison-workspace.com',
+ required: true,
+ paramVisibility: 'user-only',
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ },
+ ]
+}
+
+export function buildEmailBisonOutputs(): Record {
+ return {
+ eventType: { type: 'string', description: 'Email Bison webhook event type' },
+ eventName: { type: 'string', description: 'Human-readable Email Bison event name' },
+ instanceUrl: { type: 'string', description: 'Email Bison instance URL' },
+ workspaceId: { type: 'number', description: 'Email Bison workspace ID' },
+ workspaceName: { type: 'string', description: 'Email Bison workspace name' },
+ event: { type: 'json', description: 'Raw Email Bison event metadata object' },
+ data: { type: 'json', description: 'Raw Email Bison event data object' },
+ }
+}
+
+export function buildEmailBisonEmailSentOutputs(): Record {
+ return {
+ ...buildEmailBisonOutputs(),
+ scheduledEmail: {
+ id: { type: 'number', description: 'Scheduled email ID' },
+ lead_id: { type: 'number', description: 'Lead ID' },
+ sequence_step_id: { type: 'number', description: 'Sequence step ID' },
+ sequence_step_order: { type: 'number', description: 'Sequence step order' },
+ sequence_step_variant: { type: 'number', description: 'Sequence step variant' },
+ email_subject: { type: 'string', description: 'Email subject' },
+ email_body: { type: 'string', description: 'Email body HTML' },
+ status: { type: 'string', description: 'Scheduled email status' },
+ scheduled_date_est: { type: 'string', description: 'Scheduled date in EST' },
+ scheduled_date_local: { type: 'string', description: 'Scheduled date in local timezone' },
+ local_timezone: { type: 'string', description: 'Scheduled email local timezone' },
+ sent_at: { type: 'string', description: 'Email sent timestamp' },
+ opens: { type: 'number', description: 'Open count' },
+ replies: { type: 'number', description: 'Reply count' },
+ unique_opens: { type: 'number', description: 'Unique open count' },
+ unique_replies: { type: 'number', description: 'Unique reply count' },
+ interested: { type: 'string', description: 'Interested status' },
+ raw_message_id: { type: 'string', description: 'Raw email message ID' },
+ },
+ campaignEvent: {
+ id: { type: 'number', description: 'Campaign event ID' },
+ event_type: { type: 'string', description: 'Campaign event type' },
+ created_at_local: { type: 'string', description: 'Campaign event local creation timestamp' },
+ local_timezone: { type: 'string', description: 'Campaign event local timezone' },
+ created_at: { type: 'string', description: 'Campaign event creation timestamp' },
+ },
+ lead: {
+ id: { type: 'number', description: 'Lead ID' },
+ email: { type: 'string', description: 'Lead email address' },
+ first_name: { type: 'string', description: 'Lead first name' },
+ last_name: { type: 'string', description: 'Lead last name' },
+ status: { type: 'string', description: 'Lead status' },
+ title: { type: 'string', description: 'Lead title' },
+ company: { type: 'string', description: 'Lead company' },
+ custom_variables: { type: 'json', description: 'Lead custom variables' },
+ emails_sent: { type: 'number', description: 'Lead emails sent count' },
+ opens: { type: 'number', description: 'Lead open count' },
+ unique_opens: { type: 'number', description: 'Lead unique open count' },
+ replies: { type: 'number', description: 'Lead reply count' },
+ unique_replies: { type: 'number', description: 'Lead unique reply count' },
+ bounces: { type: 'number', description: 'Lead bounce count' },
+ },
+ campaign: {
+ id: { type: 'number', description: 'Campaign ID' },
+ name: { type: 'string', description: 'Campaign name' },
+ },
+ senderEmail: {
+ id: { type: 'number', description: 'Sender email ID' },
+ name: { type: 'string', description: 'Sender email name' },
+ email: { type: 'string', description: 'Sender email address' },
+ status: { type: 'string', description: 'Sender email status' },
+ account_type: { type: 'string', description: 'Sender email connection type' },
+ daily_limit: { type: 'number', description: 'Sender email daily limit' },
+ emails_sent: { type: 'number', description: 'Sender email sent count' },
+ replied: { type: 'number', description: 'Sender email replied count' },
+ opened: { type: 'number', description: 'Sender email opened count' },
+ unsubscribed: { type: 'number', description: 'Sender email unsubscribed count' },
+ bounced: { type: 'number', description: 'Sender email bounced count' },
+ unique_replies: { type: 'number', description: 'Sender email unique reply count' },
+ unique_opens: { type: 'number', description: 'Sender email unique open count' },
+ total_leads_contacted: { type: 'number', description: 'Sender email total leads contacted' },
+ interested: { type: 'number', description: 'Sender email interested count' },
+ created_at: { type: 'string', description: 'Sender email creation timestamp' },
+ updated_at: { type: 'string', description: 'Sender email update timestamp' },
+ },
+ }
+}
+
+export function buildEmailBisonLeadFirstContactedOutputs(): Record {
+ return buildEmailBisonEmailSentOutputs()
+}
+
+export function buildEmailBisonLeadUnsubscribedOutputs(): Record {
+ return buildEmailBisonEmailSentOutputs()
+}
+
+export function buildEmailBisonEmailOpenedOutputs(): Record {
+ return buildEmailBisonEmailSentOutputs()
+}
+
+export function buildEmailBisonLeadRepliedOutputs(): Record {
+ return {
+ ...buildEmailBisonOutputs(),
+ reply: {
+ id: { type: 'number', description: 'Reply ID' },
+ uuid: { type: 'string', description: 'Reply UUID' },
+ email_subject: { type: 'string', description: 'Reply email subject' },
+ interested: { type: 'boolean', description: 'Whether the reply is marked interested' },
+ automated_reply: { type: 'boolean', description: 'Whether the reply is automated' },
+ html_body: { type: 'string', description: 'Reply HTML body' },
+ text_body: { type: 'string', description: 'Reply plain text body' },
+ raw_body: { type: 'string', description: 'Raw MIME reply body' },
+ headers: { type: 'string', description: 'Encoded raw email headers' },
+ date_received: { type: 'string', description: 'Reply received timestamp' },
+ from_name: { type: 'string', description: 'Reply sender name' },
+ from_email_address: { type: 'string', description: 'Reply sender email address' },
+ primary_to_email_address: { type: 'string', description: 'Primary recipient email address' },
+ to: { type: 'json', description: 'Reply To recipients' },
+ cc: { type: 'json', description: 'Reply CC recipients' },
+ bcc: { type: 'json', description: 'Reply BCC recipients' },
+ parent_id: { type: 'number', description: 'Parent reply ID' },
+ reply_type: { type: 'string', description: 'Reply type' },
+ folder: { type: 'string', description: 'Reply folder' },
+ raw_message_id: { type: 'string', description: 'Raw email message ID' },
+ created_at: { type: 'string', description: 'Reply creation timestamp' },
+ updated_at: { type: 'string', description: 'Reply update timestamp' },
+ attachments: { type: 'json', description: 'Reply attachments' },
+ },
+ campaignEvent: {
+ id: { type: 'number', description: 'Campaign event ID' },
+ event_type: { type: 'string', description: 'Campaign event type' },
+ created_at_local: { type: 'string', description: 'Campaign event local creation timestamp' },
+ local_timezone: { type: 'string', description: 'Campaign event local timezone' },
+ created_at: { type: 'string', description: 'Campaign event creation timestamp' },
+ },
+ lead: {
+ id: { type: 'number', description: 'Lead ID' },
+ email: { type: 'string', description: 'Lead email address' },
+ first_name: { type: 'string', description: 'Lead first name' },
+ last_name: { type: 'string', description: 'Lead last name' },
+ status: { type: 'string', description: 'Lead status' },
+ title: { type: 'string', description: 'Lead title' },
+ company: { type: 'string', description: 'Lead company' },
+ custom_variables: { type: 'json', description: 'Lead custom variables' },
+ emails_sent: { type: 'number', description: 'Lead emails sent count' },
+ opens: { type: 'number', description: 'Lead open count' },
+ unique_opens: { type: 'number', description: 'Lead unique open count' },
+ replies: { type: 'number', description: 'Lead reply count' },
+ unique_replies: { type: 'number', description: 'Lead unique reply count' },
+ bounces: { type: 'number', description: 'Lead bounce count' },
+ },
+ campaign: {
+ id: { type: 'number', description: 'Campaign ID' },
+ name: { type: 'string', description: 'Campaign name' },
+ },
+ scheduledEmail: {
+ id: { type: 'number', description: 'Scheduled email ID' },
+ sequence_step_id: { type: 'number', description: 'Sequence step ID' },
+ sequence_step_order: { type: 'number', description: 'Sequence step order' },
+ sequence_step_variant: { type: 'number', description: 'Sequence step variant' },
+ status: { type: 'string', description: 'Scheduled email status' },
+ scheduled_date_est: { type: 'string', description: 'Scheduled date in EST' },
+ scheduled_date_local: { type: 'string', description: 'Scheduled date in local timezone' },
+ local_timezone: { type: 'string', description: 'Scheduled email local timezone' },
+ sent_at: { type: 'string', description: 'Email sent timestamp' },
+ opens: { type: 'number', description: 'Open count' },
+ replies: { type: 'number', description: 'Reply count' },
+ unique_opens: { type: 'number', description: 'Unique open count' },
+ unique_replies: { type: 'number', description: 'Unique reply count' },
+ interested: { type: 'string', description: 'Interested status' },
+ raw_message_id: { type: 'string', description: 'Raw email message ID' },
+ },
+ senderEmail: {
+ id: { type: 'number', description: 'Sender email ID' },
+ name: { type: 'string', description: 'Sender email name' },
+ email: { type: 'string', description: 'Sender email address' },
+ status: { type: 'string', description: 'Sender email status' },
+ account_type: { type: 'string', description: 'Sender email connection type' },
+ daily_limit: { type: 'number', description: 'Sender email daily limit' },
+ emails_sent: { type: 'number', description: 'Sender email sent count' },
+ replied: { type: 'number', description: 'Sender email replied count' },
+ opened: { type: 'number', description: 'Sender email opened count' },
+ unsubscribed: { type: 'number', description: 'Sender email unsubscribed count' },
+ bounced: { type: 'number', description: 'Sender email bounced count' },
+ unique_replies: { type: 'number', description: 'Sender email unique reply count' },
+ unique_opens: { type: 'number', description: 'Sender email unique open count' },
+ total_leads_contacted: { type: 'number', description: 'Sender email total leads contacted' },
+ interested: { type: 'number', description: 'Sender email interested count' },
+ created_at: { type: 'string', description: 'Sender email creation timestamp' },
+ updated_at: { type: 'string', description: 'Sender email update timestamp' },
+ },
+ }
+}
+
+export function buildEmailBisonLeadInterestedOutputs(): Record {
+ return buildEmailBisonLeadRepliedOutputs()
+}
+
+export function buildEmailBisonEmailBouncedOutputs(): Record {
+ return buildEmailBisonLeadRepliedOutputs()
+}
+
+export function buildEmailBisonManualEmailSentOutputs(): Record {
+ return {
+ ...buildEmailBisonOutputs(),
+ reply: {
+ id: { type: 'number', description: 'Reply ID' },
+ email_subject: { type: 'string', description: 'Reply email subject' },
+ interested: { type: 'boolean', description: 'Whether the reply is marked interested' },
+ automated_reply: { type: 'boolean', description: 'Whether the reply is automated' },
+ html_body: { type: 'string', description: 'Reply HTML body' },
+ text_body: { type: 'string', description: 'Reply plain text body' },
+ raw_body: { type: 'string', description: 'Raw MIME reply body' },
+ headers: { type: 'string', description: 'Encoded raw email headers' },
+ date_received: { type: 'string', description: 'Reply received timestamp' },
+ reply_type: { type: 'string', description: 'Reply type' },
+ from_name: { type: 'string', description: 'Reply sender name' },
+ from_email_address: { type: 'string', description: 'Reply sender email address' },
+ primary_to_email_address: { type: 'string', description: 'Primary recipient email address' },
+ to: { type: 'json', description: 'Reply To recipients' },
+ cc: { type: 'json', description: 'Reply CC recipients' },
+ bcc: { type: 'json', description: 'Reply BCC recipients' },
+ parent_id: { type: 'json', description: 'Parent reply ID' },
+ folder: { type: 'string', description: 'Reply folder' },
+ raw_message_id: { type: 'string', description: 'Raw email message ID' },
+ created_at: { type: 'string', description: 'Reply creation timestamp' },
+ updated_at: { type: 'string', description: 'Reply update timestamp' },
+ attachments: { type: 'json', description: 'Reply attachments' },
+ },
+ lead: {
+ id: { type: 'number', description: 'Lead ID' },
+ email: { type: 'string', description: 'Lead email address' },
+ first_name: { type: 'string', description: 'Lead first name' },
+ last_name: { type: 'string', description: 'Lead last name' },
+ status: { type: 'string', description: 'Lead status' },
+ title: { type: 'string', description: 'Lead title' },
+ company: { type: 'string', description: 'Lead company' },
+ custom_variables: { type: 'json', description: 'Lead custom variables' },
+ emails_sent: { type: 'number', description: 'Lead emails sent count' },
+ opens: { type: 'number', description: 'Lead open count' },
+ unique_opens: { type: 'number', description: 'Lead unique open count' },
+ replies: { type: 'number', description: 'Lead reply count' },
+ unique_replies: { type: 'number', description: 'Lead unique reply count' },
+ bounces: { type: 'number', description: 'Lead bounce count' },
+ },
+ campaign: {
+ id: { type: 'number', description: 'Campaign ID' },
+ name: { type: 'string', description: 'Campaign name' },
+ },
+ scheduledEmail: {
+ id: { type: 'number', description: 'Scheduled email ID' },
+ sequence_step_id: { type: 'number', description: 'Sequence step ID' },
+ sequence_step_order: { type: 'number', description: 'Sequence step order' },
+ sequence_step_variant: { type: 'number', description: 'Sequence step variant' },
+ status: { type: 'string', description: 'Scheduled email status' },
+ scheduled_date_est: { type: 'string', description: 'Scheduled date in EST' },
+ scheduled_date_local: { type: 'string', description: 'Scheduled date in local timezone' },
+ local_timezone: { type: 'string', description: 'Scheduled email local timezone' },
+ sent_at: { type: 'string', description: 'Email sent timestamp' },
+ opens: { type: 'number', description: 'Open count' },
+ replies: { type: 'number', description: 'Reply count' },
+ unique_opens: { type: 'number', description: 'Unique open count' },
+ unique_replies: { type: 'number', description: 'Unique reply count' },
+ interested: { type: 'json', description: 'Interested status' },
+ raw_message_id: { type: 'string', description: 'Raw email message ID' },
+ },
+ senderEmail: {
+ id: { type: 'number', description: 'Sender email ID' },
+ name: { type: 'string', description: 'Sender email name' },
+ email: { type: 'string', description: 'Sender email address' },
+ status: { type: 'string', description: 'Sender email status' },
+ account_type: { type: 'string', description: 'Sender email connection type' },
+ daily_limit: { type: 'number', description: 'Sender email daily limit' },
+ emails_sent: { type: 'number', description: 'Sender email sent count' },
+ replied: { type: 'number', description: 'Sender email replied count' },
+ opened: { type: 'number', description: 'Sender email opened count' },
+ unsubscribed: { type: 'number', description: 'Sender email unsubscribed count' },
+ bounced: { type: 'number', description: 'Sender email bounced count' },
+ unique_replies: { type: 'number', description: 'Sender email unique reply count' },
+ unique_opens: { type: 'number', description: 'Sender email unique open count' },
+ total_leads_contacted: { type: 'number', description: 'Sender email total leads contacted' },
+ interested: { type: 'number', description: 'Sender email interested count' },
+ created_at: { type: 'string', description: 'Sender email creation timestamp' },
+ updated_at: { type: 'string', description: 'Sender email update timestamp' },
+ },
+ }
+}
+
+export function buildEmailBisonTagAttachedOutputs(): Record {
+ return {
+ ...buildEmailBisonOutputs(),
+ tagId: { type: 'number', description: 'Email Bison tag ID' },
+ tagName: { type: 'string', description: 'Email Bison tag name' },
+ taggableId: { type: 'number', description: 'ID of the tagged resource' },
+ taggableType: { type: 'string', description: 'Type of the tagged resource' },
+ }
+}
+
+export function buildEmailBisonTagRemovedOutputs(): Record {
+ return buildEmailBisonTagAttachedOutputs()
+}
+
+export function buildEmailBisonEmailAccountAddedOutputs(): Record {
+ return {
+ ...buildEmailBisonOutputs(),
+ senderEmail: {
+ id: { type: 'number', description: 'Sender email ID' },
+ name: { type: 'string', description: 'Sender email name' },
+ email: { type: 'string', description: 'Sender email address' },
+ status: { type: 'string', description: 'Sender email status' },
+ account_type: { type: 'string', description: 'Sender email connection type' },
+ daily_limit: { type: 'number', description: 'Sender email daily limit' },
+ emails_sent: { type: 'number', description: 'Sender email sent count' },
+ replied: { type: 'number', description: 'Sender email replied count' },
+ opened: { type: 'number', description: 'Sender email opened count' },
+ unsubscribed: { type: 'number', description: 'Sender email unsubscribed count' },
+ bounced: { type: 'number', description: 'Sender email bounced count' },
+ unique_replies: { type: 'number', description: 'Sender email unique reply count' },
+ unique_opens: { type: 'number', description: 'Sender email unique open count' },
+ total_leads_contacted: { type: 'number', description: 'Sender email total leads contacted' },
+ interested: { type: 'number', description: 'Sender email interested count' },
+ created_at: { type: 'string', description: 'Sender email creation timestamp' },
+ updated_at: { type: 'string', description: 'Sender email update timestamp' },
+ },
+ }
+}
+
+export function buildEmailBisonEmailAccountRemovedOutputs(): Record {
+ return buildEmailBisonEmailAccountAddedOutputs()
+}
+
+export function buildEmailBisonEmailAccountDisconnectedOutputs(): Record {
+ return buildEmailBisonEmailAccountAddedOutputs()
+}
+
+export function buildEmailBisonEmailAccountReconnectedOutputs(): Record {
+ return buildEmailBisonEmailAccountAddedOutputs()
+}
+
+export function buildEmailBisonWarmupDisabledReceivingBouncesOutputs(): Record<
+ string,
+ TriggerOutput
+> {
+ return buildEmailBisonEmailAccountAddedOutputs()
+}
+
+export function buildEmailBisonWarmupDisabledCausingBouncesOutputs(): Record<
+ string,
+ TriggerOutput
+> {
+ return buildEmailBisonEmailAccountAddedOutputs()
+}
+
+export function buildEmailBisonUntrackedReplyReceivedOutputs(): Record {
+ return {
+ ...buildEmailBisonOutputs(),
+ reply: {
+ id: { type: 'number', description: 'Reply ID' },
+ uuid: { type: 'string', description: 'Reply UUID' },
+ email_subject: { type: 'string', description: 'Reply email subject' },
+ interested: { type: 'boolean', description: 'Whether the reply is marked interested' },
+ automated_reply: { type: 'boolean', description: 'Whether the reply is automated' },
+ html_body: { type: 'string', description: 'Reply HTML body' },
+ text_body: { type: 'string', description: 'Reply plain text body' },
+ raw_body: { type: 'string', description: 'Raw MIME reply body' },
+ headers: { type: 'string', description: 'Encoded raw email headers' },
+ date_received: { type: 'string', description: 'Reply received timestamp' },
+ from_name: { type: 'string', description: 'Reply sender name' },
+ from_email_address: { type: 'string', description: 'Reply sender email address' },
+ primary_to_email_address: { type: 'string', description: 'Primary recipient email address' },
+ to: { type: 'json', description: 'Reply To recipients' },
+ cc: { type: 'json', description: 'Reply CC recipients' },
+ bcc: { type: 'json', description: 'Reply BCC recipients' },
+ parent_id: { type: 'number', description: 'Parent reply ID' },
+ reply_type: { type: 'string', description: 'Reply type' },
+ folder: { type: 'string', description: 'Reply folder' },
+ raw_message_id: { type: 'string', description: 'Raw email message ID' },
+ created_at: { type: 'string', description: 'Reply creation timestamp' },
+ updated_at: { type: 'string', description: 'Reply update timestamp' },
+ attachments: { type: 'json', description: 'Reply attachments' },
+ },
+ senderEmail: {
+ id: { type: 'number', description: 'Sender email ID' },
+ name: { type: 'string', description: 'Sender email name' },
+ email: { type: 'string', description: 'Sender email address' },
+ status: { type: 'string', description: 'Sender email status' },
+ account_type: { type: 'string', description: 'Sender email connection type' },
+ daily_limit: { type: 'number', description: 'Sender email daily limit' },
+ emails_sent: { type: 'number', description: 'Sender email sent count' },
+ replied: { type: 'number', description: 'Sender email replied count' },
+ opened: { type: 'number', description: 'Sender email opened count' },
+ unsubscribed: { type: 'number', description: 'Sender email unsubscribed count' },
+ bounced: { type: 'number', description: 'Sender email bounced count' },
+ unique_replies: { type: 'number', description: 'Sender email unique reply count' },
+ unique_opens: { type: 'number', description: 'Sender email unique open count' },
+ total_leads_contacted: { type: 'number', description: 'Sender email total leads contacted' },
+ interested: { type: 'number', description: 'Sender email interested count' },
+ created_at: { type: 'string', description: 'Sender email creation timestamp' },
+ updated_at: { type: 'string', description: 'Sender email update timestamp' },
+ },
+ }
+}
+
+export function getEmailBisonEventTypeForTrigger(triggerId: string): string | undefined {
+ return EMAILBISON_TRIGGER_TO_EVENT_TYPE[
+ triggerId as keyof typeof EMAILBISON_TRIGGER_TO_EVENT_TYPE
+ ]
+}
+
+export function isEmailBisonEventMatch(triggerId: string, body: Record): boolean {
+ const expectedEventType = getEmailBisonEventTypeForTrigger(triggerId)
+ if (!expectedEventType) return false
+
+ const event = body.event
+ if (!isRecord(event)) return false
+
+ const actualEventType = event.type
+ return typeof actualEventType === 'string' && actualEventType.toLowerCase() === expectedEventType
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+}
diff --git a/apps/sim/triggers/emailbison/warmup_disabled_causing_bounces.ts b/apps/sim/triggers/emailbison/warmup_disabled_causing_bounces.ts
new file mode 100644
index 00000000000..341138bb833
--- /dev/null
+++ b/apps/sim/triggers/emailbison/warmup_disabled_causing_bounces.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonWarmupDisabledCausingBouncesOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonWarmupDisabledCausingBouncesTrigger: TriggerConfig = {
+ id: 'emailbison_warmup_disabled_causing_bounces',
+ name: 'Email Bison Warmup Disabled Causing Bounces',
+ provider: 'emailbison',
+ description: 'Trigger when warmup is disabled for a sender email causing too many bounces',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_warmup_disabled_causing_bounces',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Warmup Disabled Causing Bounces'),
+ extraFields: buildEmailBisonExtraFields('emailbison_warmup_disabled_causing_bounces'),
+ }),
+ outputs: buildEmailBisonWarmupDisabledCausingBouncesOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/emailbison/warmup_disabled_receiving_bounces.ts b/apps/sim/triggers/emailbison/warmup_disabled_receiving_bounces.ts
new file mode 100644
index 00000000000..6ecfcf2b4e1
--- /dev/null
+++ b/apps/sim/triggers/emailbison/warmup_disabled_receiving_bounces.ts
@@ -0,0 +1,26 @@
+import { EmailBisonIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
+import {
+ buildEmailBisonExtraFields,
+ buildEmailBisonWarmupDisabledReceivingBouncesOutputs,
+ emailBisonSetupInstructions,
+ emailBisonTriggerOptions,
+} from '@/triggers/emailbison/utils'
+import type { TriggerConfig } from '@/triggers/types'
+
+export const emailBisonWarmupDisabledReceivingBouncesTrigger: TriggerConfig = {
+ id: 'emailbison_warmup_disabled_receiving_bounces',
+ name: 'Email Bison Warmup Disabled Receiving Bounces',
+ provider: 'emailbison',
+ description: 'Trigger when warmup is disabled for a sender email receiving too many bounces',
+ version: '1.0.0',
+ icon: EmailBisonIcon,
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'emailbison_warmup_disabled_receiving_bounces',
+ triggerOptions: emailBisonTriggerOptions,
+ setupInstructions: emailBisonSetupInstructions('Warmup Disabled Receiving Bounces'),
+ extraFields: buildEmailBisonExtraFields('emailbison_warmup_disabled_receiving_bounces'),
+ }),
+ outputs: buildEmailBisonWarmupDisabledReceivingBouncesOutputs(),
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
+}
diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts
index b32941173ab..b1d747f529f 100644
--- a/apps/sim/triggers/registry.ts
+++ b/apps/sim/triggers/registry.ts
@@ -78,6 +78,25 @@ import {
confluenceUserCreatedTrigger,
confluenceWebhookTrigger,
} from '@/triggers/confluence'
+import {
+ emailBisonEmailAccountAddedTrigger,
+ emailBisonEmailAccountDisconnectedTrigger,
+ emailBisonEmailAccountReconnectedTrigger,
+ emailBisonEmailAccountRemovedTrigger,
+ emailBisonEmailBouncedTrigger,
+ emailBisonEmailOpenedTrigger,
+ emailBisonEmailSentTrigger,
+ emailBisonLeadFirstContactedTrigger,
+ emailBisonLeadInterestedTrigger,
+ emailBisonLeadRepliedTrigger,
+ emailBisonLeadUnsubscribedTrigger,
+ emailBisonManualEmailSentTrigger,
+ emailBisonTagAttachedTrigger,
+ emailBisonTagRemovedTrigger,
+ emailBisonUntrackedReplyReceivedTrigger,
+ emailBisonWarmupDisabledCausingBouncesTrigger,
+ emailBisonWarmupDisabledReceivingBouncesTrigger,
+} from '@/triggers/emailbison'
import { fathomNewMeetingTrigger, fathomWebhookTrigger } from '@/triggers/fathom'
import { firefliesTranscriptionCompleteTrigger } from '@/triggers/fireflies'
import { genericWebhookTrigger } from '@/triggers/generic'
@@ -379,6 +398,23 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
confluence_space_removed: confluenceSpaceRemovedTrigger,
confluence_page_permissions_updated: confluencePagePermissionsUpdatedTrigger,
confluence_user_created: confluenceUserCreatedTrigger,
+ emailbison_email_sent: emailBisonEmailSentTrigger,
+ emailbison_lead_first_contacted: emailBisonLeadFirstContactedTrigger,
+ emailbison_lead_replied: emailBisonLeadRepliedTrigger,
+ emailbison_lead_interested: emailBisonLeadInterestedTrigger,
+ emailbison_lead_unsubscribed: emailBisonLeadUnsubscribedTrigger,
+ emailbison_untracked_reply_received: emailBisonUntrackedReplyReceivedTrigger,
+ emailbison_email_opened: emailBisonEmailOpenedTrigger,
+ emailbison_email_bounced: emailBisonEmailBouncedTrigger,
+ emailbison_email_account_added: emailBisonEmailAccountAddedTrigger,
+ emailbison_email_account_removed: emailBisonEmailAccountRemovedTrigger,
+ emailbison_email_account_disconnected: emailBisonEmailAccountDisconnectedTrigger,
+ emailbison_email_account_reconnected: emailBisonEmailAccountReconnectedTrigger,
+ emailbison_manual_email_sent: emailBisonManualEmailSentTrigger,
+ emailbison_tag_attached: emailBisonTagAttachedTrigger,
+ emailbison_tag_removed: emailBisonTagRemovedTrigger,
+ emailbison_warmup_disabled_receiving_bounces: emailBisonWarmupDisabledReceivingBouncesTrigger,
+ emailbison_warmup_disabled_causing_bounces: emailBisonWarmupDisabledCausingBouncesTrigger,
generic_webhook: genericWebhookTrigger,
greenhouse_candidate_hired: greenhouseCandidateHiredTrigger,
greenhouse_new_application: greenhouseNewApplicationTrigger,
From 369f9b613de42628d48c7435c31f17626059c4b6 Mon Sep 17 00:00:00 2001
From: Waleed
Date: Wed, 6 May 2026 13:03:09 -0700
Subject: [PATCH 10/17] fix(office-excel): support Office.js add-in embed and
surface Graph errors (#4479)
* fix(office-excel): support Office.js add-in embed and surface Graph errors
* fix(office-excel): delegate to parseGraphErrorFromData and handle array embed param
---
.../api/tools/microsoft_excel/drives/route.ts | 20 +---
.../api/tools/microsoft_excel/sheets/route.ts | 14 +--
.../chat/[identifier]/office-embed-init.tsx | 50 +++++++++
apps/sim/app/chat/[identifier]/page.tsx | 19 +++-
apps/sim/lib/core/security/csp.test.ts | 19 ++++
apps/sim/lib/core/security/csp.ts | 15 ++-
apps/sim/tools/error-extractors.ts | 10 ++
apps/sim/tools/microsoft_excel/read.ts | 18 ++-
apps/sim/tools/microsoft_excel/table_add.ts | 2 +
apps/sim/tools/microsoft_excel/utils.test.ts | 90 +++++++++++++++
apps/sim/tools/microsoft_excel/utils.ts | 106 ++++++++++++++++++
.../tools/microsoft_excel/worksheet_add.ts | 15 +--
apps/sim/tools/microsoft_excel/write.ts | 18 +++
13 files changed, 354 insertions(+), 42 deletions(-)
create mode 100644 apps/sim/app/chat/[identifier]/office-embed-init.tsx
create mode 100644 apps/sim/tools/microsoft_excel/utils.test.ts
diff --git a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts
index df884bea928..2e0d1d80e43 100644
--- a/apps/sim/app/api/tools/microsoft_excel/drives/route.ts
+++ b/apps/sim/app/api/tools/microsoft_excel/drives/route.ts
@@ -7,7 +7,7 @@ import { validatePathSegment, validateSharePointSiteId } from '@/lib/core/securi
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
-import { GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
+import { extractGraphError, GRAPH_ID_PATTERN } from '@/tools/microsoft_excel/utils'
export const dynamic = 'force-dynamic'
@@ -76,13 +76,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
if (!response.ok) {
- const errorData = await response
- .json()
- .catch(() => ({ error: { message: 'Unknown error' } }))
- return NextResponse.json(
- { error: errorData.error?.message || 'Failed to fetch drive' },
- { status: response.status }
- )
+ const errorMessage = await extractGraphError(response)
+ return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data: GraphDrive = await response.json()
@@ -102,15 +97,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
if (!response.ok) {
- const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
+ const errorMessage = await extractGraphError(response)
logger.error(`[${requestId}] Microsoft Graph API error fetching drives`, {
status: response.status,
- error: errorData.error?.message,
+ error: errorMessage,
})
- return NextResponse.json(
- { error: errorData.error?.message || 'Failed to fetch drives' },
- { status: response.status }
- )
+ return NextResponse.json({ error: errorMessage }, { status: response.status })
}
const data = await response.json()
diff --git a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts
index 7a2c64cf6c3..19212aaa59a 100644
--- a/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts
+++ b/apps/sim/app/api/tools/microsoft_excel/sheets/route.ts
@@ -6,7 +6,7 @@ import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
-import { getItemBasePath } from '@/tools/microsoft_excel/utils'
+import { extractGraphError, getItemBasePath } from '@/tools/microsoft_excel/utils'
export const dynamic = 'force-dynamic'
@@ -73,18 +73,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
})
if (!worksheetsResponse.ok) {
- const errorData = await worksheetsResponse
- .text()
- .then((text) => JSON.parse(text))
- .catch(() => ({ error: { message: 'Unknown error' } }))
+ const errorMessage = await extractGraphError(worksheetsResponse)
logger.error(`[${requestId}] Microsoft Graph API error`, {
status: worksheetsResponse.status,
- error: errorData.error?.message || 'Failed to fetch worksheets',
+ error: errorMessage,
})
- return NextResponse.json(
- { error: errorData.error?.message || 'Failed to fetch worksheets' },
- { status: worksheetsResponse.status }
- )
+ return NextResponse.json({ error: errorMessage }, { status: worksheetsResponse.status })
}
const data: WorksheetsResponse = await worksheetsResponse.json()
diff --git a/apps/sim/app/chat/[identifier]/office-embed-init.tsx b/apps/sim/app/chat/[identifier]/office-embed-init.tsx
new file mode 100644
index 00000000000..02729d65274
--- /dev/null
+++ b/apps/sim/app/chat/[identifier]/office-embed-init.tsx
@@ -0,0 +1,50 @@
+'use client'
+
+import Script from 'next/script'
+
+declare global {
+ interface Window {
+ Office?: {
+ onReady: () => Promise<{ host: string | null; platform: string | null }>
+ }
+ }
+}
+
+/**
+ * Office.js nullifies window.history.replaceState and pushState (a legacy
+ * IE10 workaround inside the library) which breaks Next.js's client-side
+ * router. Cache the originals at module load — before Trello connection failed. Redirecting...