diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
index e0afc85422e..59f9d1ccca2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
@@ -93,7 +93,13 @@ export function ToolCallItem({
return (
{BlockIcon && (
-
+ // Size via inline style: a custom block's image icon carries a trailing
+ // `size-full` that defeats size *classes* (it fills tiled surfaces), so a
+ // class-only size renders the uploaded icon at natural size here.
+
)}
{isExecuting ? (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
index beac3a100cb..ec1dc3e630f 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/search-modal/search-modal.tsx
@@ -97,6 +97,7 @@ export function SearchModal({
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
+ const currentWorkflowId = params.workflowId as string | undefined
const inputRef = useRef(null)
const [mounted, setMounted] = useState(false)
const { navigateToSettings } = useSettingsNavigation()
@@ -581,23 +582,25 @@ export function SearchModal({
*/
const filteredBlocks = useMemo(() => {
if (!isOnWorkflowPage) return []
+ // A custom block is hidden on its own source workflow's canvas — placing it
+ // there recurses (same exclusion as the toolbar).
return filterAndCap(
- blocks,
+ blocks.filter((b) => !b.sourceWorkflowId || b.sourceWorkflowId !== currentWorkflowId),
(b) => b.name,
deferredSearch,
(b) => b.searchValue
)
- }, [isOnWorkflowPage, blocks, deferredSearch])
+ }, [isOnWorkflowPage, blocks, deferredSearch, currentWorkflowId])
const filteredTools = useMemo(() => {
if (!isOnWorkflowPage) return []
return filterAndCap(
- tools,
+ tools.filter((t) => !t.sourceWorkflowId || t.sourceWorkflowId !== currentWorkflowId),
(t) => t.name,
deferredSearch,
(t) => t.searchValue
)
- }, [isOnWorkflowPage, tools, deferredSearch])
+ }, [isOnWorkflowPage, tools, deferredSearch, currentWorkflowId])
const filteredTriggers = useMemo(() => {
if (!isOnWorkflowPage) return []
diff --git a/apps/sim/blocks/custom/build-config.test.ts b/apps/sim/blocks/custom/build-config.test.ts
index 68aa7b5a457..83122e6a6d3 100644
--- a/apps/sim/blocks/custom/build-config.test.ts
+++ b/apps/sim/blocks/custom/build-config.test.ts
@@ -8,6 +8,7 @@ import {
CUSTOM_BLOCK_TILE_COLOR,
type CustomBlockRow,
isCustomBlockType,
+ isReservedOutputName,
} from '@/blocks/custom/build-config'
import type { BlockIcon } from '@/blocks/types'
@@ -33,6 +34,18 @@ describe('isCustomBlockType', () => {
})
})
+describe('isReservedOutputName', () => {
+ it('rejects the system output fields case-insensitively', () => {
+ expect(isReservedOutputName('cost')).toBe(true)
+ expect(isReservedOutputName('Cost')).toBe(true)
+ expect(isReservedOutputName(' success ')).toBe(true)
+ expect(isReservedOutputName('error')).toBe(true)
+ expect(isReservedOutputName('result')).toBe(false)
+ expect(isReservedOutputName('cost_2')).toBe(false)
+ expect(isReservedOutputName('summary')).toBe(false)
+ })
+})
+
describe('buildCustomBlockConfig', () => {
const fields: WorkflowInputField[] = [
{ name: 'title', type: 'string' },
@@ -47,6 +60,7 @@ describe('buildCustomBlockConfig', () => {
const config = buildCustomBlockConfig(row, fields, { icon })
expect(config.type).toBe('custom_block_abc123')
expect(config.name).toBe('Invoice Parser')
+ expect(config.sourceWorkflowId).toBe('wf-1')
expect(config.category).toBe('tools')
expect(config.bgColor).toBe(CUSTOM_BLOCK_TILE_COLOR)
expect(config.hideFromToolbar).toBeUndefined()
diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts
index 62cc473e7c9..e8f02973d5e 100644
--- a/apps/sim/blocks/custom/build-config.ts
+++ b/apps/sim/blocks/custom/build-config.ts
@@ -62,6 +62,20 @@ export const RESERVED_PARAMS = new Set([
'advancedMode',
])
+/**
+ * Output names the block projects itself (`success`/`error` from `buildOutputs`,
+ * `cost` from the executor's billing aggregation). A user-named exposed output
+ * must never shadow these — an output literally named `cost` would clobber the
+ * billed cost. `result` is deliberately NOT reserved: it only exists as a system
+ * field when no outputs are curated, which cannot co-occur with a named output.
+ */
+export const RESERVED_OUTPUT_NAMES = new Set(['success', 'error', 'cost'])
+
+/** Whether an exposed-output name collides with a system output field. */
+export function isReservedOutputName(name: string): boolean {
+ return RESERVED_OUTPUT_NAMES.has(name.trim().toLowerCase())
+}
+
/** Map a Start input field type to the editor sub-block type used to collect it. */
function subBlockTypeForField(fieldType: string): SubBlockType {
switch (fieldType) {
@@ -122,6 +136,7 @@ export function buildCustomBlockConfig(
type: row.type,
name: row.name,
description: row.description,
+ sourceWorkflowId: row.workflowId,
category: 'tools',
longDescription:
'A published workflow packaged as a reusable, self-contained block. Fill its input ' +
diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts
index d063f43d38a..4bd7b82d0bf 100644
--- a/apps/sim/blocks/types.ts
+++ b/apps/sim/blocks/types.ts
@@ -468,6 +468,12 @@ export interface BlockConfig {
}
}
hideFromToolbar?: boolean
+ /**
+ * For published custom blocks only: the bound source workflow's id. Discovery
+ * surfaces use it to hide a workflow's own block on that workflow's canvas
+ * (placing it would recurse).
+ */
+ sourceWorkflowId?: string
/**
* Marks an unreleased block. Preview blocks are hidden from every discovery
* surface (toolbar, search, mentions, copilot/VFS, docs) in every environment —
diff --git a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
index 7eb676229d2..f60a85b95ce 100644
--- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
+++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
@@ -32,7 +32,11 @@ import { saveDiscardActions } from '@/app/workspace/[workspaceId]/settings/compo
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard'
-import type { CustomBlockInput, CustomBlockOutput } from '@/blocks/custom/build-config'
+import {
+ type CustomBlockInput,
+ type CustomBlockOutput,
+ isReservedOutputName,
+} from '@/blocks/custom/build-config'
import { SettingRow } from '@/ee/components/setting-row'
import {
useCustomBlocks,
@@ -56,12 +60,12 @@ const decodeOutput = (value: string) => {
: { blockId: value.slice(0, i), path: value.slice(i + OUTPUT_SEP.length) }
}
-/** Derive a unique, friendly output name from a dot-path, avoiding collisions. */
+/** Derive a unique, friendly output name from a dot-path, avoiding collisions and reserved names. */
function deriveOutputName(path: string, taken: Set): string {
const base = (path.split('.').pop() || path).replace(/[^a-zA-Z0-9_]/g, '_')
let name = base
let n = 2
- while (taken.has(name)) name = `${base}_${n++}`
+ while (taken.has(name) || isReservedOutputName(name)) name = `${base}_${n++}`
taken.add(name)
return name
}
@@ -360,6 +364,11 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
setError('Output names must be unique')
return
}
+ const reserved = exposedOutputs.find((o) => isReservedOutputName(o.name))
+ if (reserved) {
+ setError(`"${reserved.name}" is a reserved output name (success, error, cost)`)
+ return
+ }
// Only the placeholder and required flag are authored; the field set/name/type
// are always derived from the deployed Start. Persist only non-empty overrides.
const inputPlaceholders = visibleInputs
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
index db21354265f..3f13cead423 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
@@ -518,6 +518,45 @@ describe('WorkflowBlockHandler', () => {
})
})
})
+
+ describe('projectCustomBlockOutput', () => {
+ const childResult = {
+ success: true,
+ output: { data: 'whole result' },
+ logs: [{ blockId: 'b1', success: true, output: { data: { x: 42 }, price: 999 } }],
+ }
+
+ it('maps each curated output to its named field plus system fields', () => {
+ const result = (handler as any).projectCustomBlockOutput(
+ childResult,
+ [{ blockId: 'b1', path: 'data.x', name: 'answer' }],
+ 0.5
+ )
+
+ expect(result).toEqual({ answer: 42, success: true, cost: { total: 0.5 } })
+ })
+
+ it('never lets an exposed output named cost clobber the billed cost', () => {
+ const result = (handler as any).projectCustomBlockOutput(
+ childResult,
+ [{ blockId: 'b1', path: 'price', name: 'cost' }],
+ 0.5
+ )
+
+ expect(result.cost).toEqual({ total: 0.5 })
+ expect(result.success).toBe(true)
+ })
+
+ it('exposes the whole child result when no outputs are curated', () => {
+ const result = (handler as any).projectCustomBlockOutput(childResult, [], 0.5)
+
+ expect(result).toEqual({
+ success: true,
+ result: { data: 'whole result' },
+ cost: { total: 0.5 },
+ })
+ })
+ })
})
describe('remapCustomBlockInputKeys', () => {
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts
index 7656e008044..406dea79b8f 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts
@@ -888,14 +888,15 @@ export class WorkflowBlockHandler implements BlockHandler {
return { success: true, result: executionResult.output ?? {}, ...cost }
}
const logs = executionResult.logs ?? []
- const output: Record = { success: true, ...cost }
+ const output: Record = {}
for (const { blockId, path, name } of exposedOutputs) {
const log =
[...logs].reverse().find((l) => l.blockId === blockId && l.success) ??
[...logs].reverse().find((l) => l.blockId === blockId)
output[name] = log ? getValueAtPath(log.output, path) : undefined
}
- return output as BlockOutput
+ // System fields spread last — pre-validation rows may still name an output cost/success.
+ return { ...output, success: true, ...cost } as BlockOutput
}
private mapChildOutputToParent(
diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts
index e7629109324..77da3a0213b 100644
--- a/apps/sim/lib/api/contracts/custom-blocks.ts
+++ b/apps/sim/lib/api/contracts/custom-blocks.ts
@@ -1,6 +1,7 @@
import { z } from 'zod'
import { workflowIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
+import { isReservedOutputName } from '@/blocks/custom/build-config'
const inputFieldSchema = z.object({
/** Stable per-field id — preserved so client block configs key sub-blocks on it
@@ -36,6 +37,21 @@ const exposedOutputSchema = z.object({
name: z.string().min(1).max(60),
})
+/**
+ * Publish/update variant: rejects reserved system output names (`success`,
+ * `error`, `cost`) that would shadow the block's own projected fields. The
+ * read schema stays lenient so rows that predate this validation still parse.
+ */
+const exposedOutputWriteSchema = exposedOutputSchema.extend({
+ name: z
+ .string()
+ .min(1)
+ .max(60)
+ .refine((name) => !isReservedOutputName(name), {
+ message: 'Output name is reserved (success, error, cost)',
+ }),
+})
+
export const customBlockSchema = z.object({
id: z.string(),
organizationId: z.string(),
@@ -87,7 +103,7 @@ export const publishCustomBlockBodySchema = z.object({
/** Per-input placeholder hints keyed by Start field id; the field set itself is always derived from the deployment. */
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
/** Curated outputs; omit/empty to expose the child's whole result. */
- exposedOutputs: z.array(exposedOutputSchema).max(50).optional(),
+ exposedOutputs: z.array(exposedOutputWriteSchema).max(50).optional(),
})
export type PublishCustomBlockBody = z.input
@@ -104,7 +120,7 @@ export const updateCustomBlockBodySchema = z
/** A URL (https or internal serve path) sets/replaces the icon; `null` clears it (default icon). */
iconUrl: iconUrlSchema.nullable().optional(),
inputs: z.array(inputPlaceholderSchema).max(50).optional(),
- exposedOutputs: z.array(exposedOutputSchema).max(50).optional(),
+ exposedOutputs: z.array(exposedOutputWriteSchema).max(50).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field is required' })
diff --git a/apps/sim/lib/workflows/custom-blocks/operations.test.ts b/apps/sim/lib/workflows/custom-blocks/operations.test.ts
new file mode 100644
index 00000000000..a16a5726b40
--- /dev/null
+++ b/apps/sim/lib/workflows/custom-blocks/operations.test.ts
@@ -0,0 +1,67 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/billing/core/subscription', () => ({
+ isOrganizationOnEnterprisePlan: vi.fn(),
+}))
+
+vi.mock('@/lib/core/config/feature-flags', () => ({
+ isFeatureEnabled: vi.fn(),
+}))
+
+vi.mock('@/lib/workflows/input-format', () => ({
+ extractInputFieldsFromBlocks: vi.fn(),
+}))
+
+vi.mock('@/lib/workflows/persistence/utils', () => ({
+ loadDeployedWorkflowState: vi.fn(),
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getWorkspaceWithOwner: vi.fn(),
+}))
+
+import {
+ CustomBlockValidationError,
+ publishCustomBlock,
+ updateCustomBlock,
+} from '@/lib/workflows/custom-blocks/operations'
+
+const publishParams = {
+ organizationId: 'org-1',
+ workspaceId: 'ws-1',
+ workflowId: 'wf-1',
+ userId: 'user-1',
+ name: 'Enrich Lead',
+ description: '',
+}
+
+describe('reserved exposed-output names', () => {
+ it('publishCustomBlock rejects an output named cost', async () => {
+ await expect(
+ publishCustomBlock({
+ ...publishParams,
+ exposedOutputs: [{ blockId: 'b1', path: 'price', name: 'cost' }],
+ })
+ ).rejects.toThrow(CustomBlockValidationError)
+ })
+
+ it('publishCustomBlock rejects reserved names case-insensitively', async () => {
+ await expect(
+ publishCustomBlock({
+ ...publishParams,
+ exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'Success' }],
+ })
+ ).rejects.toThrow('"Success" is a reserved output name (success, error, cost)')
+ })
+
+ it('updateCustomBlock rejects a reserved output name', async () => {
+ await expect(
+ updateCustomBlock('cb-1', {
+ exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'error' }],
+ })
+ ).rejects.toThrow(CustomBlockValidationError)
+ })
+})
diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts
index 643867dd611..ad3ff18bd1a 100644
--- a/apps/sim/lib/workflows/custom-blocks/operations.ts
+++ b/apps/sim/lib/workflows/custom-blocks/operations.ts
@@ -15,7 +15,7 @@ import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/wor
import { loadDeployedWorkflowState } from '@/lib/workflows/persistence/utils'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import type { CustomBlockOutput, CustomBlockRow } from '@/blocks/custom/build-config'
-import { CUSTOM_BLOCK_TYPE_PREFIX } from '@/blocks/custom/build-config'
+import { CUSTOM_BLOCK_TYPE_PREFIX, isReservedOutputName } from '@/blocks/custom/build-config'
const logger = createLogger('CustomBlocksOperations')
@@ -362,6 +362,19 @@ export class CustomBlockValidationError extends Error {
}
}
+/**
+ * Reject exposed outputs whose name shadows a system output field. Authoritative
+ * check: also covers callers that bypass the HTTP contract (copilot handler).
+ */
+function assertNoReservedOutputNames(exposedOutputs: CustomBlockOutput[] | undefined): void {
+ const reserved = exposedOutputs?.find((o) => isReservedOutputName(o.name))
+ if (reserved) {
+ throw new CustomBlockValidationError(
+ `"${reserved.name}" is a reserved output name (success, error, cost)`
+ )
+ }
+}
+
/**
* Publish a deployed workflow as an org-wide custom block. The source workflow
* must live in `workspaceId` — the workspace the caller was verified to admin —
@@ -393,6 +406,8 @@ export async function publishCustomBlock(params: {
exposedOutputs,
} = params
+ assertNoReservedOutputNames(exposedOutputs)
+
const [wf] = await db
.select({
id: workflow.id,
@@ -487,6 +502,7 @@ export async function updateCustomBlock(
exposedOutputs?: CustomBlockOutput[]
}
): Promise {
+ if (updates.exposedOutputs !== undefined) assertNoReservedOutputNames(updates.exposedOutputs)
const patch: Partial = { updatedAt: new Date() }
if (updates.name !== undefined) patch.name = updates.name
if (updates.description !== undefined) patch.description = updates.description
diff --git a/apps/sim/stores/modals/search/store.ts b/apps/sim/stores/modals/search/store.ts
index 65a84b2d627..efce810b781 100644
--- a/apps/sim/stores/modals/search/store.ts
+++ b/apps/sim/stores/modals/search/store.ts
@@ -105,6 +105,7 @@ export const useSearchModalStore = create()(
bgColor: block.bgColor || '#6B7280',
type: block.type,
searchValue: `${block.name} ${block.type} ${buildCommandSearchableOptionSearchValue(block)}`,
+ sourceWorkflowId: block.sourceWorkflowId,
}
if (block.category === 'blocks' && block.type !== 'starter') {
diff --git a/apps/sim/stores/modals/search/types.ts b/apps/sim/stores/modals/search/types.ts
index b8315e8156e..32146b9472b 100644
--- a/apps/sim/stores/modals/search/types.ts
+++ b/apps/sim/stores/modals/search/types.ts
@@ -12,6 +12,8 @@ export interface SearchBlockItem {
type: string
config?: BlockConfig
searchValue?: string
+ /** Custom blocks only: bound source workflow id — hidden on that workflow's canvas. */
+ sourceWorkflowId?: string
}
/**