diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts
new file mode 100644
index 00000000000..f7a59bed7f7
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/[id]/route.ts
@@ -0,0 +1,94 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import {
+ deleteCustomBlockContract,
+ updateCustomBlockContract,
+} from '@/lib/api/contracts/custom-blocks'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ CustomBlockValidationError,
+ deleteCustomBlock,
+ getCustomBlockManageContext,
+ updateCustomBlock,
+} from '@/lib/workflows/custom-blocks/operations'
+import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('CustomBlockAPI')
+
+type RouteContext = { params: Promise<{ id: string }> }
+
+/**
+ * Confirm the caller can manage (edit/delete) the block: admin of the block's
+ * SOURCE workflow's workspace — matching who could publish it. Org admins/owners
+ * hold admin on every org workspace, so they pass too; a workspace admin from a
+ * different workspace does not, so they cannot alter another workspace's block or
+ * its exposed outputs.
+ */
+async function authorizeManage(userId: string, id: string) {
+ const ctx = await getCustomBlockManageContext(id)
+ if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) }
+
+ if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
+ return {
+ error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
+ }
+ }
+ if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
+ return { error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) }
+ }
+ return { error: null }
+}
+
+export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(updateCustomBlockContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const authz = await authorizeManage(session.user.id, id)
+ if (authz.error) return authz.error
+
+ const { name, description, enabled, iconUrl, exposedOutputs } = parsed.data.body
+ try {
+ await updateCustomBlock(id, {
+ name,
+ description,
+ enabled,
+ iconUrl,
+ exposedOutputs,
+ })
+ return NextResponse.json({ success: true as const })
+ } catch (error) {
+ if (error instanceof CustomBlockValidationError) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ logger.error('Failed to update custom block', { id, error: getErrorMessage(error) })
+ throw error
+ }
+})
+
+export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(deleteCustomBlockContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const { id } = parsed.data.params
+ const authz = await authorizeManage(session.user.id, id)
+ if (authz.error) return authz.error
+
+ await deleteCustomBlock(id)
+ return NextResponse.json({ success: true as const })
+})
diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts
new file mode 100644
index 00000000000..c244ae88ad4
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/route.ts
@@ -0,0 +1,130 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import {
+ listCustomBlocksContract,
+ publishCustomBlockContract,
+} from '@/lib/api/contracts/custom-blocks'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ CustomBlockValidationError,
+ type CustomBlockWithInputs,
+ listCustomBlocksWithInputs,
+ publishCustomBlock,
+} from '@/lib/workflows/custom-blocks/operations'
+import {
+ checkWorkspaceAccess,
+ getWorkspaceWithOwner,
+ hasWorkspaceAdminAccess,
+} from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('CustomBlocksAPI')
+
+/** Wire shape for a custom block. Keeps the icon field name explicit for the client. */
+function toWire(block: CustomBlockWithInputs) {
+ return {
+ id: block.id,
+ organizationId: block.organizationId,
+ workflowId: block.workflowId,
+ type: block.type,
+ name: block.name,
+ description: block.description,
+ iconUrl: block.iconUrl,
+ enabled: block.enabled,
+ inputFields: block.inputFields,
+ exposedOutputs: block.exposedOutputs,
+ }
+}
+
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(listCustomBlocksContract, request, {})
+ if (!parsed.success) return parsed.response
+
+ const userId = session.user.id
+ const { workspaceId } = parsed.data.query
+
+ const access = await checkWorkspaceAccess(workspaceId, userId)
+ if (!access.hasAccess) {
+ return NextResponse.json({ error: 'Access denied' }, { status: 403 })
+ }
+
+ const organizationId = access.workspace?.organizationId
+ if (!organizationId) {
+ return NextResponse.json({ enabled: false, customBlocks: [] })
+ }
+
+ if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) {
+ return NextResponse.json({ enabled: false, customBlocks: [] })
+ }
+
+ const enabled = await isOrganizationOnEnterprisePlan(organizationId)
+ const blocks = enabled ? await listCustomBlocksWithInputs(organizationId) : []
+ return NextResponse.json({ enabled, customBlocks: blocks.map(toWire) })
+})
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(publishCustomBlockContract, request, {})
+ if (!parsed.success) return parsed.response
+
+ const userId = session.user.id
+ const { workspaceId, workflowId, name, description, iconUrl, exposedOutputs } = parsed.data.body
+
+ if (!(await hasWorkspaceAdminAccess(userId, workspaceId))) {
+ return NextResponse.json({ error: 'Admin permissions required' }, { status: 403 })
+ }
+
+ const ws = await getWorkspaceWithOwner(workspaceId)
+ if (!ws?.organizationId) {
+ return NextResponse.json(
+ { error: 'Publishing a block requires the workspace to belong to an organization' },
+ { status: 400 }
+ )
+ }
+ const organizationId = ws.organizationId
+
+ if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: organizationId }))) {
+ return NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 })
+ }
+
+ if (!(await isOrganizationOnEnterprisePlan(organizationId))) {
+ return NextResponse.json(
+ { error: 'Deploy as block requires an enterprise plan' },
+ { status: 403 }
+ )
+ }
+
+ try {
+ const block = await publishCustomBlock({
+ organizationId,
+ workspaceId,
+ workflowId,
+ userId,
+ name,
+ description,
+ iconUrl,
+ exposedOutputs,
+ })
+ return NextResponse.json({ customBlock: toWire(block) })
+ } catch (error) {
+ if (error instanceof CustomBlockValidationError) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ logger.error('Failed to publish custom block', { error: getErrorMessage(error) })
+ throw error
+ }
+})
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index 4cd544c27bb..16bc63964a5 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -61,6 +61,7 @@ import {
cleanupExecutionBase64Cache,
hydrateUserFilesWithBase64,
} from '@/lib/uploads/utils/user-file-base64.server'
+import { getCustomBlockRowsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
import { type ExecutionEvent, encodeSSEEvent } from '@/lib/workflows/executor/execution-events'
@@ -72,6 +73,7 @@ import {
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution'
+import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
import {
PublicApiNotAllowedError,
validatePublicApiAllowed,
@@ -859,12 +861,17 @@ async function handleExecutePost(
variables: deployedVariables,
}
- const serializedWorkflow = new Serializer().serializeWorkflow(
- workflowData.blocks,
- workflowData.edges,
- workflowData.loops,
- workflowData.parallels,
- false
+ // Custom blocks resolve only inside the org overlay; wrap this pre-execution
+ // serialize (used for input file-field discovery) the same way the core does.
+ const customBlockRows = await getCustomBlockRowsForWorkspace(workspaceId)
+ const serializedWorkflow = await withCustomBlockOverlay(customBlockRows, async () =>
+ new Serializer().serializeWorkflow(
+ workflowData.blocks,
+ workflowData.edges,
+ workflowData.loops,
+ workflowData.parallels,
+ false
+ )
)
const executionContext = {
diff --git a/apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx b/apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx
new file mode 100644
index 00000000000..d9845d42012
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/drop-zone.tsx
@@ -0,0 +1,42 @@
+'use client'
+
+import { useState } from 'react'
+import { cn } from '@sim/emcn'
+
+interface DropZoneProps {
+ onDrop: (e: React.DragEvent) => void
+ children: React.ReactNode
+ className?: string
+}
+
+/** File drop target with a dashed accent overlay while dragging. Shared by the
+ * whitelabeling settings and the deploy-as-block icon upload. */
+export function DropZone({ onDrop, children, className }: DropZoneProps) {
+ const [isDragging, setIsDragging] = useState(false)
+
+ return (
+
{
+ if (e.dataTransfer.types.includes('Files')) {
+ e.preventDefault()
+ setIsDragging(true)
+ }
+ }}
+ onDragLeave={(e) => {
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) {
+ setIsDragging(false)
+ }
+ }}
+ onDrop={(e) => {
+ setIsDragging(false)
+ onDrop(e)
+ }}
+ >
+ {children}
+ {isDragging && (
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index adbaa7d368a..4fdea0c52d6 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -7,6 +7,7 @@ import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome'
import { prefetchWorkspaceSidebar } from '@/app/workspace/[workspaceId]/prefetch'
+import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -43,6 +44,7 @@ export default async function WorkspaceLayout({
+
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
index bacca64af3b..9ea1131717c 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view/trace-view.tsx
@@ -46,6 +46,7 @@ import {
isIterationType,
parseTime,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
+import { isCustomBlockType } from '@/blocks/custom/build-config'
import { useCodeViewerFeatures } from '@/hooks/use-code-viewer'
const DEFAULT_TREE_PANE_WIDTH = 240
@@ -667,7 +668,10 @@ const TraceDetailPane = memo(function TraceDetailPane({ span }: { span: TraceSpa
const endedAt = parseTime(span.endTime)
const metaEntries: { label: string; value: string }[] = []
- metaEntries.push({ label: 'Type', value: span.type })
+ metaEntries.push({
+ label: 'Type',
+ value: isCustomBlockType(span.type) ? 'custom block' : span.type,
+ })
metaEntries.push({ label: 'Duration', value: formatDuration(duration, { precision: 2 }) || '—' })
if (span.provider) metaEntries.push({ label: 'Provider', value: span.provider })
if (span.model) metaEntries.push({ label: 'Model', value: span.model })
diff --git a/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx b/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx
new file mode 100644
index 00000000000..7d781602434
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/providers/custom-blocks-loader.tsx
@@ -0,0 +1,49 @@
+'use client'
+
+import { useEffect } from 'react'
+import { useParams } from 'next/navigation'
+import { buildCustomBlockConfig } from '@/blocks/custom/build-config'
+import { hydrateClientCustomBlocks } from '@/blocks/custom/client-overlay'
+import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
+import { useCustomBlocks } from '@/hooks/queries/custom-blocks'
+
+/**
+ * Hydrates the client custom-block registry overlay from the active workspace's
+ * org custom blocks. Mounted once in the workspace layout so every surface that
+ * resolves blocks synchronously — the canvas, the block palette, copilot mentions,
+ * and the Access Control "Blocks" list — sees custom blocks. Re-hydrates on
+ * workspace switch (the query key changes) and on any publish/edit/unpublish.
+ */
+export function CustomBlocksLoader() {
+ const params = useParams()
+ const workspaceId = params?.workspaceId as string | undefined
+ const { data } = useCustomBlocks(workspaceId)
+
+ useEffect(() => {
+ hydrateClientCustomBlocks(
+ // Only enabled blocks are resolvable/executable server-side, so the client
+ // overlay (toolbar, canvas, palette) must exclude disabled ones too — else
+ // the block is offered but every run fails.
+ (data ?? [])
+ .filter((block) => block.enabled)
+ .map((block) =>
+ buildCustomBlockConfig(
+ {
+ type: block.type,
+ name: block.name,
+ description: block.description,
+ workflowId: block.workflowId,
+ exposedOutputs: block.exposedOutputs,
+ },
+ block.inputFields,
+ {
+ icon: getCustomBlockIcon(block.iconUrl),
+ bgColor: block.iconUrl ? 'transparent' : undefined,
+ }
+ )
+ )
+ )
+ }, [data])
+
+ return null
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/block.tsx
new file mode 100644
index 00000000000..0864b26696e
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/block.tsx
@@ -0,0 +1,403 @@
+'use client'
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import {
+ Button,
+ ChipCombobox,
+ ChipInput,
+ ChipTextarea,
+ type ComboboxOptionGroup,
+ Loader,
+ toast,
+} from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import { Image as ImageIcon, X } from 'lucide-react'
+import {
+ type FlattenOutputsBlockInput,
+ type FlattenOutputsEdgeInput,
+ flattenWorkflowOutputs,
+} from '@/lib/workflows/blocks/flatten-outputs'
+import { DropZone } from '@/app/workspace/[workspaceId]/components/drop-zone'
+import { useProfilePictureUpload } from '@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload'
+import type { CustomBlockOutput } from '@/blocks/custom/build-config'
+import { SettingRow } from '@/ee/components/setting-row'
+import {
+ useCustomBlocks,
+ useDeleteCustomBlock,
+ usePublishCustomBlock,
+ useUpdateCustomBlock,
+} from '@/hooks/queries/custom-blocks'
+import { useWorkflowState } from '@/hooks/queries/workflows'
+
+const OUTPUT_SEP = '::'
+const ICON_ACCEPT = 'image/png,image/jpeg,image/jpg,image/svg+xml,image/webp'
+
+const encodeOutput = (blockId: string, path: string) => `${blockId}${OUTPUT_SEP}${path}`
+const decodeOutput = (value: string) => {
+ const i = value.indexOf(OUTPUT_SEP)
+ return i === -1
+ ? { blockId: '', path: value }
+ : { blockId: value.slice(0, i), path: value.slice(i + OUTPUT_SEP.length) }
+}
+
+/** Derive a unique, friendly output name from a dot-path, avoiding collisions. */
+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++}`
+ taken.add(name)
+ return name
+}
+
+interface BlockDeployProps {
+ workflowId: string | null
+ workspaceId: string
+ isDeployed: boolean
+ canAdmin: boolean
+ /** Lifted state so the modal footer (shared with the other tabs) owns the actions. */
+ onSubmittingChange?: (submitting: boolean) => void
+ onCanSaveChange?: (canSave: boolean) => void
+ onExistingChange?: (existing: boolean) => void
+}
+
+export function BlockDeploy({
+ workflowId,
+ workspaceId,
+ isDeployed,
+ canAdmin,
+ onSubmittingChange,
+ onCanSaveChange,
+ onExistingChange,
+}: BlockDeployProps) {
+ const { data: customBlocks = [] } = useCustomBlocks(workspaceId)
+ const existing = useMemo(
+ () => customBlocks.find((b) => b.workflowId === workflowId) ?? null,
+ [customBlocks, workflowId]
+ )
+
+ const publish = usePublishCustomBlock(workspaceId)
+ const update = useUpdateCustomBlock(workspaceId)
+ const remove = useDeleteCustomBlock(workspaceId)
+
+ const [name, setName] = useState(existing?.name ?? '')
+ const [description, setDescription] = useState(existing?.description ?? '')
+ /** Curated outputs (with editable names); empty = expose the whole result. */
+ const [outputs, setOutputs] = useState(() => existing?.exposedOutputs ?? [])
+ const [error, setError] = useState(null)
+
+ // `existing` arrives async from useCustomBlocks; the useState seeds above only run
+ // on first render. Reseed when the resolved block identity changes (nothing →
+ // loaded, or unpublish → nothing) so the form reflects real data instead of
+ // staying empty and offering a duplicate publish. Keyed on the id (stable across
+ // refetches) so it never clobbers in-progress edits.
+ const existingId = existing?.id ?? null
+ const prevExistingIdRef = useRef(existingId)
+ if (prevExistingIdRef.current !== existingId) {
+ prevExistingIdRef.current = existingId
+ setName(existing?.name ?? '')
+ setDescription(existing?.description ?? '')
+ setOutputs(existing?.exposedOutputs ?? [])
+ }
+
+ const iconUpload = useProfilePictureUpload({
+ currentImage: existing?.iconUrl ?? null,
+ onError: (e) => setError(e),
+ context: 'workspace-logos',
+ workspaceId,
+ })
+
+ const workflowState = useWorkflowState(workflowId ?? undefined)
+ const { outputGroups, labelByKey } = useMemo(() => {
+ const state = workflowState.data as
+ | { blocks?: Record; edges?: FlattenOutputsEdgeInput[] }
+ | null
+ | undefined
+ const labels = new Map()
+ if (!state?.blocks) return { outputGroups: [] as ComboboxOptionGroup[], labelByKey: labels }
+ const flat = flattenWorkflowOutputs(Object.values(state.blocks), state.edges ?? [])
+ const byBlock = new Map<
+ string,
+ { blockName: string; items: { label: string; value: string }[] }
+ >()
+ for (const f of flat) {
+ const key = encodeOutput(f.blockId, f.path)
+ labels.set(key, `${f.blockName} › ${f.path}`)
+ const group = byBlock.get(f.blockId) ?? { blockName: f.blockName, items: [] }
+ group.items.push({ label: f.path, value: key })
+ byBlock.set(f.blockId, group)
+ }
+ const groups = Array.from(byBlock.values()).map((g) => ({
+ section: g.blockName,
+ items: g.items,
+ }))
+ return { outputGroups: groups, labelByKey: labels }
+ }, [workflowState.data])
+
+ /** Curated outputs that still resolve to a block in the (loaded) workflow. An
+ * output whose block was deleted no longer appears here, so it is neither shown
+ * nor saved. While the workflow is still loading we keep every stored output to
+ * avoid dropping valid ones before their blocks are known. */
+ const outputsLoaded = Boolean(workflowState.data) && !workflowState.isLoading
+ const visibleOutputs = useMemo(
+ () =>
+ outputsLoaded
+ ? outputs.filter((o) => labelByKey.has(encodeOutput(o.blockId, o.path)))
+ : outputs,
+ [outputs, outputsLoaded, labelByKey]
+ )
+
+ const selectedOutputKeys = useMemo(
+ () => visibleOutputs.map((o) => encodeOutput(o.blockId, o.path)),
+ [visibleOutputs]
+ )
+
+ /** Reconcile the picker's selection: keep existing rows (and their names), add
+ * new picks with a derived default name, drop removed ones. */
+ function handleOutputsChange(nextKeys: string[]) {
+ const byKey = new Map(outputs.map((o) => [encodeOutput(o.blockId, o.path), o]))
+ const taken = new Set(outputs.map((o) => o.name))
+ setOutputs(
+ nextKeys.map((key) => {
+ const existingOutput = byKey.get(key)
+ if (existingOutput) return existingOutput
+ const { blockId, path } = decodeOutput(key)
+ return { blockId, path, name: deriveOutputName(path, taken) }
+ })
+ )
+ }
+
+ function setOutputName(key: string, value: string) {
+ setOutputs((prev) =>
+ prev.map((o) => (encodeOutput(o.blockId, o.path) === key ? { ...o, name: value } : o))
+ )
+ }
+
+ const iconUrl = iconUpload.previewUrl
+ const isBusy = publish.isPending || update.isPending || remove.isPending
+
+ // Only enable "Update" when something actually changed (clear feedback on what
+ // needs saving); publishing a new block just needs a name.
+ const dirty = existing
+ ? name.trim() !== existing.name ||
+ description.trim() !== (existing.description ?? '') ||
+ (iconUrl || null) !== (existing.iconUrl ?? null) ||
+ JSON.stringify(visibleOutputs) !== JSON.stringify(existing.exposedOutputs)
+ : true
+
+ const canSave = canAdmin && name.trim().length > 0 && !isBusy && !iconUpload.isUploading && dirty
+
+ useEffect(() => onCanSaveChange?.(canSave), [canSave, onCanSaveChange])
+ useEffect(
+ () => onSubmittingChange?.(publish.isPending || update.isPending),
+ [publish.isPending, update.isPending, onSubmittingChange]
+ )
+ useEffect(() => onExistingChange?.(Boolean(existing)), [existing, onExistingChange])
+
+ async function handleSubmit() {
+ setError(null)
+ const exposedOutputs = visibleOutputs.map((o) => ({ ...o, name: o.name.trim() }))
+ if (exposedOutputs.some((o) => !o.name)) {
+ setError('Every exposed output needs a name')
+ return
+ }
+ if (new Set(exposedOutputs.map((o) => o.name)).size !== exposedOutputs.length) {
+ setError('Output names must be unique')
+ return
+ }
+ try {
+ if (existing) {
+ const iconChanged = (iconUrl || null) !== (existing.iconUrl ?? null)
+ await update.mutateAsync({
+ id: existing.id,
+ name: name.trim(),
+ description: description.trim(),
+ exposedOutputs,
+ ...(iconChanged ? { iconUrl: iconUrl || null } : {}),
+ })
+ toast.success('Block updated')
+ } else {
+ if (!workflowId) return
+ await publish.mutateAsync({
+ workspaceId,
+ workflowId,
+ name: name.trim(),
+ description: description.trim(),
+ exposedOutputs,
+ ...(iconUrl ? { iconUrl } : {}),
+ })
+ toast.success('Published as block')
+ }
+ } catch (e) {
+ setError(getErrorMessage(e, 'Failed to save block'))
+ }
+ }
+
+ async function handleUnpublish() {
+ if (!existing) return
+ setError(null)
+ try {
+ await remove.mutateAsync(existing.id)
+ setName('')
+ setDescription('')
+ setOutputs([])
+ iconUpload.handleRemove()
+ toast.success('Block unpublished')
+ } catch (e) {
+ setError(getErrorMessage(e, 'Failed to unpublish block'))
+ }
+ }
+
+ if (!isDeployed && !existing) {
+ return (
+
+ Deploy this workflow first to publish it as a block.
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/index.ts
new file mode 100644
index 00000000000..e006fe1c3d4
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/block/index.ts
@@ -0,0 +1 @@
+export { BlockDeploy } from './block'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts
index 630f2b53a5a..b082d9329a0 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/index.ts
@@ -1,4 +1,5 @@
export { ApiDeploy } from './api'
+export { BlockDeploy } from './block'
export { ChatDeploy, type ExistingChat } from './chat'
export { DeployUpgradeGate } from './deploy-upgrade-gate'
export { GeneralDeploy } from './general'
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
index 8bd6cba84e7..8174e78499a 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/deploy-modal.tsx
@@ -36,6 +36,7 @@ import type { DeployReadiness } from '@/app/workspace/[workspaceId]/w/[workflowI
import { runPreDeployChecks } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/hooks/use-predeploy-checks'
import { normalizeName, startsWithUuid } from '@/executor/constants'
import { useApiKeys } from '@/hooks/queries/api-keys'
+import { useCanPublishCustomBlock } from '@/hooks/queries/custom-blocks'
import {
invalidateDeploymentQueries,
useActivateDeploymentVersion,
@@ -56,6 +57,7 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import {
ApiDeploy,
+ BlockDeploy,
ChatDeploy,
DeployUpgradeGate,
type ExistingChat,
@@ -101,9 +103,9 @@ interface WorkflowDeploymentInfoUI {
isPublicApi: boolean
}
-type TabView = 'general' | 'api' | 'chat' | 'mcp'
+type TabView = 'general' | 'api' | 'chat' | 'mcp' | 'block'
-const DEPLOY_MODAL_TABS = new Set(['general', 'api', 'chat', 'mcp'])
+const DEPLOY_MODAL_TABS = new Set(['general', 'api', 'chat', 'mcp', 'block'])
function isDeployModalTab(value: unknown): value is TabView {
return typeof value === 'string' && DEPLOY_MODAL_TABS.has(value as TabView)
@@ -143,6 +145,10 @@ export function DeployModal({
const [mcpToolSaveDisabledReason, setMcpToolSaveDisabledReason] = useState(null)
const [mcpActiveServerId, setMcpActiveServerId] = useState(null)
+ const [blockSubmitting, setBlockSubmitting] = useState(false)
+ const [blockCanSave, setBlockCanSave] = useState(false)
+ const [blockExists, setBlockExists] = useState(false)
+
const [chatSuccess, setChatSuccess] = useState(false)
const chatSuccessTimeoutRef = useRef | null>(null)
const deployActionIdRef = useRef(0)
@@ -196,6 +202,8 @@ export function DeployModal({
const { data: mcpServers = [] } = useWorkflowMcpServers(workflowWorkspaceId || '')
const hasMcpServers = mcpServers.length > 0
+ const { data: canPublishBlock = false } = useCanPublishCustomBlock(workspaceId)
+
const deployMutation = useDeployWorkflow()
const undeployMutation = useUndeployWorkflow()
const activateVersionMutation = useActivateDeploymentVersion()
@@ -513,6 +521,17 @@ export function DeployModal({
form?.requestSubmit()
}
+ const handleBlockFormSubmit = () => {
+ const form = document.getElementById('block-deploy-form') as HTMLFormElement
+ form?.requestSubmit()
+ }
+
+ const handleBlockUnpublish = () => {
+ const form = document.getElementById('block-deploy-form') as HTMLFormElement
+ const trigger = form?.querySelector('[data-unpublish-trigger]') as HTMLButtonElement | null
+ trigger?.click()
+ }
+
const isSubmitting = deployMutation.isPending || isFinalizingDeploy
const isUndeploying = undeployMutation.isPending
@@ -538,6 +557,7 @@ export function DeployModal({
{!permissionConfig.hideDeployChatbot && (
Chat
)}
+ {canPublishBlock && Block}
@@ -628,6 +648,20 @@ export function DeployModal({
)}
+
+ {canPublishBlock && (
+
+
+
+ )}
@@ -698,6 +732,37 @@ export function DeployModal({
)}
+ {activeTab === 'block' && (
+
+
+
+ {blockExists && (
+
+ )}
+
+
+
+ )}
{activeTab === 'mcp' && !gateProgrammaticDeploy && isDeployed && hasMcpServers && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
index 0969a117502..d367fd756e1 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/toolbar.tsx
@@ -22,6 +22,7 @@ import {
} from '@sim/emcn'
import clsx from 'clsx'
import { ChevronDown, Search } from 'lucide-react'
+import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
import { captureEvent } from '@/lib/posthog/client'
import { getTriggersForSidebar, hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
@@ -29,9 +30,16 @@ import { ToolbarItemContextMenu } from '@/app/workspace/[workspaceId]/w/[workflo
import { useToolbarItemInteractions } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/toolbar/hooks'
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
+import {
+ buildCustomBlockConfig,
+ CUSTOM_BLOCK_TILE_COLOR,
+ isCustomBlockType,
+} from '@/blocks/custom/build-config'
+import { getCustomBlockIcon } from '@/blocks/custom/custom-block-icon'
import { getTileIconColorClass } from '@/blocks/icon-color'
import { getCanonicalBlocksByCategory } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
+import { useCustomBlocks } from '@/hooks/queries/custom-blocks'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useSandboxBlockConstraints } from '@/hooks/use-sandbox-block-constraints'
import { useToolbarStore } from '@/stores/panel'
@@ -193,8 +201,14 @@ let cachedTools: BlockItem[] | null = null
function ensureBlockCaches() {
if (cachedBlocks !== null && cachedTools !== null) return
- const regularBlockConfigs = getCanonicalBlocksByCategory('blocks')
- const toolConfigs = getCanonicalBlocksByCategory('tools')
+ // Exclude custom (deploy-as-block) blocks — they render in their own reactive
+ // "Custom Blocks" section, never in the static Core Blocks / Integrations caches.
+ const regularBlockConfigs = getCanonicalBlocksByCategory('blocks').filter(
+ (b) => !isCustomBlockType(b.type)
+ )
+ const toolConfigs = getCanonicalBlocksByCategory('tools').filter(
+ (b) => !isCustomBlockType(b.type)
+ )
const regularBlockItems: BlockItem[] = regularBlockConfigs.map((block) => ({
name: block.name,
@@ -351,10 +365,12 @@ export const Toolbar = memo(
const searchInputRef = useRef(null)
const triggerItemRefs = useRef>([])
const blockItemRefs = useRef>([])
+ const customBlockItemRefs = useRef>([])
const toolItemRefs = useRef>([])
const triggerRefCallbacks = useRef void>>({})
const blockRefCallbacks = useRef void>>({})
+ const customBlockRefCallbacks = useRef void>>({})
const toolRefCallbacks = useRef void>>({})
const getTriggerRefCallback = useCallback((index: number) => {
@@ -375,6 +391,15 @@ export const Toolbar = memo(
return blockRefCallbacks.current[index]
}, [])
+ const getCustomBlockRefCallback = useCallback((index: number) => {
+ if (!customBlockRefCallbacks.current[index]) {
+ customBlockRefCallbacks.current[index] = (el) => {
+ customBlockItemRefs.current[index] = el
+ }
+ }
+ return customBlockRefCallbacks.current[index]
+ }, [])
+
const getToolRefCallback = useCallback((index: number) => {
if (!toolRefCallbacks.current[index]) {
toolRefCallbacks.current[index] = (el) => {
@@ -421,10 +446,47 @@ export const Toolbar = memo(
const { handleDragStart, handleItemClick } = useToolbarItemInteractions()
+ const params = useParams()
+ const workspaceId = params?.workspaceId as string | undefined
+ const currentWorkflowId = params?.workflowId as string | undefined
+ const { data: customBlocksData } = useCustomBlocks(workspaceId)
+
const allTriggers = getTriggers()
const allBlocks = getBlocks()
const allTools = getTools()
+ // Published custom blocks are their own section. Exclude the block bound to the
+ // CURRENT workflow — adding a workflow's own block would recurse into itself.
+ const allCustomBlocks = useMemo(() => {
+ if (!customBlocksData?.length) return []
+ return customBlocksData
+ .filter((cb) => cb.workflowId !== currentWorkflowId)
+ .map((cb) => {
+ const icon = getCustomBlockIcon(cb.iconUrl)
+ // Uploaded icons render on a transparent tile (no colored box); the
+ // default glyph keeps the neutral tile so it stays visible.
+ const tileColor = cb.iconUrl ? 'transparent' : CUSTOM_BLOCK_TILE_COLOR
+ return {
+ name: cb.name,
+ type: cb.type,
+ config: buildCustomBlockConfig(
+ {
+ type: cb.type,
+ name: cb.name,
+ description: cb.description,
+ workflowId: cb.workflowId,
+ exposedOutputs: cb.exposedOutputs,
+ },
+ cb.inputFields,
+ { icon, bgColor: tileColor }
+ ),
+ icon,
+ bgColor: tileColor,
+ } satisfies BlockItem
+ })
+ .sort((a, b) => a.name.localeCompare(b.name))
+ }, [customBlocksData, currentWorkflowId])
+
const visibleTriggers = useMemo(() => {
if (sandboxAllowedBlocks !== null) return []
return filterBlocks(allTriggers)
@@ -436,6 +498,12 @@ export const Toolbar = memo(
return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type))
}, [filterBlocks, allBlocks, sandboxAllowedBlocks])
+ const visibleCustomBlocks = useMemo(() => {
+ const permitted = filterBlocks(allCustomBlocks)
+ if (sandboxAllowedBlocks === null) return permitted
+ return permitted.filter((b) => sandboxAllowedBlocks.includes(b.type))
+ }, [filterBlocks, allCustomBlocks, sandboxAllowedBlocks])
+
const visibleTools = useMemo(() => {
const permitted = filterBlocks(allTools)
if (sandboxAllowedBlocks === null) return permitted
@@ -457,6 +525,13 @@ export const Toolbar = memo(
return visibleBlocks.filter((block) => block.name.toLowerCase().includes(normalizedQuery))
}, [visibleBlocks, isSearching, normalizedQuery])
+ const filteredCustomBlocks = useMemo(() => {
+ if (!isSearching) return visibleCustomBlocks
+ return visibleCustomBlocks.filter((block) =>
+ block.name.toLowerCase().includes(normalizedQuery)
+ )
+ }, [visibleCustomBlocks, isSearching, normalizedQuery])
+
const filteredTools = useMemo(() => {
if (!isSearching) return visibleTools
return visibleTools.filter((tool) => tool.name.toLowerCase().includes(normalizedQuery))
@@ -468,6 +543,7 @@ export const Toolbar = memo(
*/
triggerItemRefs.current.length = filteredTriggers.length
blockItemRefs.current.length = filteredBlocks.length
+ customBlockItemRefs.current.length = filteredCustomBlocks.length
toolItemRefs.current.length = filteredTools.length
/**
@@ -478,6 +554,7 @@ export const Toolbar = memo(
const sectionExpanded: Record = {
triggers: isSearching ? filteredTriggers.length > 0 : expandedSections.triggers,
blocks: isSearching ? filteredBlocks.length > 0 : expandedSections.blocks,
+ customBlocks: isSearching ? filteredCustomBlocks.length > 0 : expandedSections.customBlocks,
tools: isSearching ? filteredTools.length > 0 : expandedSections.tools,
}
@@ -751,6 +828,23 @@ export const Toolbar = memo(
onItemClick={handleItemClick}
onContextMenu={handleItemContextMenu}
/>
+ {allCustomBlocks.length > 0 && (
+
+ )}
>(new Map())
const getBlockConfig = useCallback((type: string) => {
- if (!blockConfigCache.current.has(type)) {
- blockConfigCache.current.set(type, getBlock(type))
- }
- return blockConfigCache.current.get(type)
+ const cached = blockConfigCache.current.get(type)
+ if (cached) return cached
+ // Don't cache a miss: custom (deploy-as-block) blocks resolve only once the
+ // client overlay hydrates, so an early miss must re-resolve on a later render.
+ const config = getBlock(type)
+ if (config) blockConfigCache.current.set(type, config)
+ return config
}, [])
+ // Bust cached custom-block node configs when the org overlay (hydrated by
+ // CustomBlocksLoader) changes, so renames/icon edits refresh existing nodes.
+ const { data: customBlocksData } = useCustomBlocks(workspaceId)
+ useEffect(() => {
+ for (const cb of customBlocksData ?? []) blockConfigCache.current.delete(cb.type)
+ }, [customBlocksData])
+
const prevBlocksHashRef = useRef('')
const prevBlocksRef = useRef(blocks)
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
index 2e94f37a861..a337c63fc76 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx
@@ -86,6 +86,7 @@ import {
groupWorkflowsByFolder,
} from '@/app/workspace/[workspaceId]/w/components/sidebar/utils'
import { useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
+import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
@@ -373,6 +374,7 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
const { config: permissionConfig, filterBlocks } = usePermissionConfig()
const { navigateToSettings, getSettingsHref } = useSettingsNavigation()
const initializeSearchData = useSearchModalStore((state) => state.initializeData)
+ const customBlockOverlayVersion = useCustomBlockOverlayVersion()
const providers = useProvidersStore((state) => state.providers)
const providerModelSignature = useMemo(
() =>
@@ -384,7 +386,7 @@ export const Sidebar = memo(function Sidebar({ isCollapsed }: SidebarProps) {
useEffect(() => {
initializeSearchData(filterBlocks)
- }, [initializeSearchData, filterBlocks, providerModelSignature])
+ }, [initializeSearchData, filterBlocks, providerModelSignature, customBlockOverlayVersion])
const setSidebarWidth = useSidebarStore((state) => state.setSidebarWidth)
const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed)
diff --git a/apps/sim/blocks/custom/build-config.test.ts b/apps/sim/blocks/custom/build-config.test.ts
new file mode 100644
index 00000000000..1c81186648e
--- /dev/null
+++ b/apps/sim/blocks/custom/build-config.test.ts
@@ -0,0 +1,123 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import type { WorkflowInputField } from '@/lib/workflows/input-format'
+import {
+ buildCustomBlockConfig,
+ CUSTOM_BLOCK_TILE_COLOR,
+ type CustomBlockRow,
+ isCustomBlockType,
+} from '@/blocks/custom/build-config'
+import type { BlockIcon } from '@/blocks/types'
+
+const icon: BlockIcon = () => null as never
+
+const row: CustomBlockRow = {
+ type: 'custom_block_abc123',
+ name: 'Invoice Parser',
+ description: 'Extracts fields from an invoice',
+ workflowId: 'wf-1',
+}
+
+function findSub(config: ReturnType, id: string) {
+ return config.subBlocks.find((s) => s.id === id)
+}
+
+describe('isCustomBlockType', () => {
+ it('matches only the custom_block_ prefix', () => {
+ expect(isCustomBlockType('custom_block_abc')).toBe(true)
+ expect(isCustomBlockType('agent')).toBe(false)
+ expect(isCustomBlockType(undefined)).toBe(false)
+ expect(isCustomBlockType(null)).toBe(false)
+ })
+})
+
+describe('buildCustomBlockConfig', () => {
+ const fields: WorkflowInputField[] = [
+ { name: 'title', type: 'string' },
+ { name: 'count', type: 'number' },
+ { name: 'flag', type: 'boolean' },
+ { name: 'payload', type: 'object' },
+ { name: 'items', type: 'array' },
+ { name: 'docs', type: 'file[]' },
+ ]
+
+ it('carries the row identity and always wires the workflow_executor tool', () => {
+ const config = buildCustomBlockConfig(row, fields, { icon })
+ expect(config.type).toBe('custom_block_abc123')
+ expect(config.name).toBe('Invoice Parser')
+ expect(config.category).toBe('tools')
+ expect(config.bgColor).toBe(CUSTOM_BLOCK_TILE_COLOR)
+ expect(config.hideFromToolbar).toBeUndefined()
+ expect(config.tools.access).toEqual(['workflow_executor'])
+ expect(config.tools.config?.tool({})).toBe('workflow_executor')
+ })
+
+ it('bakes the bound workflowId as a hidden sub-block', () => {
+ const config = buildCustomBlockConfig(row, fields, { icon })
+ const wf = findSub(config, 'workflowId')
+ expect(wf?.hidden).toBe(true)
+ expect(wf?.value?.({})).toBe('wf-1')
+ })
+
+ it('maps each input field type to the right sub-block', () => {
+ const config = buildCustomBlockConfig(row, fields, { icon })
+ expect(findSub(config, 'title')?.type).toBe('short-input')
+ expect(findSub(config, 'count')?.type).toBe('short-input')
+ expect(findSub(config, 'flag')?.type).toBe('switch')
+ expect(findSub(config, 'payload')?.type).toBe('code')
+ expect(findSub(config, 'payload')?.language).toBe('json')
+ expect(findSub(config, 'items')?.type).toBe('code')
+ expect(findSub(config, 'docs')?.type).toBe('file-upload')
+ expect(findSub(config, 'docs')?.multiple).toBe(true)
+ })
+
+ it('exposes the full result and hides plumbing when no outputs are curated', () => {
+ const config = buildCustomBlockConfig(row, fields, { icon })
+ expect(Object.keys(config.outputs).sort()).toEqual(['error', 'result', 'success'])
+ expect(config.outputs.childWorkflowId).toBeUndefined()
+ expect(config.outputs.childTraceSpans).toBeUndefined()
+ })
+
+ it('exposes only curated outputs as named fields', () => {
+ const config = buildCustomBlockConfig(
+ { ...row, exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'email' }] },
+ fields,
+ { icon }
+ )
+ expect(config.outputs.email).toEqual({ type: 'json', description: 'Output: content' })
+ expect(config.outputs.result).toBeUndefined()
+ expect(config.outputs.success).toBeDefined()
+ expect(config.outputs.childWorkflowId).toBeUndefined()
+ })
+
+ it('anchors the sub-block on the stable field id, showing the name as title', () => {
+ const config = buildCustomBlockConfig(row, [{ id: 'fld-1', name: 'title', type: 'string' }], {
+ icon,
+ })
+ const sub = findSub(config, 'fld-1')
+ expect(sub).toBeDefined()
+ expect(sub?.title).toBe('title')
+ expect(findSub(config, 'title')).toBeUndefined()
+ })
+
+ it('falls back to the field name as id when a field has no stable id', () => {
+ const config = buildCustomBlockConfig(row, [{ name: 'legacy', type: 'string' }], { icon })
+ expect(findSub(config, 'legacy')?.title).toBe('legacy')
+ })
+
+ it('assembles inputMapping from non-reserved, non-empty params', () => {
+ const config = buildCustomBlockConfig(row, fields, { icon })
+ const mappingFn = findSub(config, 'inputMapping')?.value
+ const json = mappingFn?.({
+ workflowId: 'wf-1',
+ inputMapping: 'ignored',
+ triggerMode: true,
+ title: 'Acme',
+ count: 3,
+ empty: '',
+ })
+ expect(JSON.parse(json as string)).toEqual({ title: 'Acme', count: 3 })
+ })
+})
diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts
new file mode 100644
index 00000000000..9879838a375
--- /dev/null
+++ b/apps/sim/blocks/custom/build-config.ts
@@ -0,0 +1,168 @@
+import type { SubBlockType } from '@sim/workflow-types/blocks'
+import type { WorkflowInputField } from '@/lib/workflows/input-format'
+import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types'
+
+/**
+ * The block-type prefix that identifies a custom (deploy-as-block) block. Shared
+ * by the registry overlay, the executor handler dispatch, and access control.
+ */
+export const CUSTOM_BLOCK_TYPE_PREFIX = 'custom_block_'
+
+/** Whether a block type is a published custom block. */
+export function isCustomBlockType(type: string | undefined | null): boolean {
+ return typeof type === 'string' && type.startsWith(CUSTOM_BLOCK_TYPE_PREFIX)
+}
+
+/** Tile background for custom-block icons (the uploaded image renders on top). */
+export const CUSTOM_BLOCK_TILE_COLOR = '#6F6F6F'
+
+/** A curated output exposed on the block, mapped from a child block output. */
+export interface CustomBlockOutput {
+ blockId: string
+ path: string
+ name: string
+}
+
+/**
+ * The DB-backed identity + presentation of a custom block. `workflowId` is the
+ * bound source workflow whose LATEST deployment this block always executes.
+ */
+export interface CustomBlockRow {
+ type: string
+ name: string
+ description: string
+ workflowId: string
+ /** Curated exposed outputs; empty/absent exposes the child's whole `result`. */
+ exposedOutputs?: CustomBlockOutput[]
+}
+
+/**
+ * Params that carry the block's own wiring rather than a mapped Start input.
+ * Everything else on the block is collected into the child `inputMapping`.
+ */
+const RESERVED_PARAMS = new Set(['workflowId', 'inputMapping', 'triggerMode', 'advancedMode'])
+
+/** Map a Start input field type to the editor sub-block type used to collect it. */
+function subBlockTypeForField(fieldType: string): SubBlockType {
+ switch (fieldType) {
+ case 'boolean':
+ return 'switch'
+ case 'object':
+ case 'array':
+ return 'code'
+ case 'file[]':
+ return 'file-upload'
+ default:
+ return 'short-input'
+ }
+}
+
+/**
+ * Synthesize a `BlockConfig` for a published custom block from its DB row and the
+ * live-derived Start input fields. Shared by the client (real icon + per-field
+ * editors) and the server (placeholder icon + `inputFields: []`, since the
+ * `inputMapping` wiring is schema-agnostic).
+ *
+ * Execution reuses the `workflow_executor` tool: the bound `workflowId` and the
+ * assembled `inputMapping` are hidden, baked sub-blocks; each Start input becomes
+ * its own editable sub-block whose value is collected into `inputMapping`.
+ * `` inside those values resolve at execution exactly like the
+ * `workflow_input` block.
+ *
+ * The sub-block id is the field's stable id (`field.id`), NOT its display name, so
+ * renaming a Start input in the source workflow and redeploying never orphans a
+ * consumer's placed value. The name is shown as the sub-block title and is what
+ * the child workflow ultimately receives — the id→name remap happens at execution
+ * in `WorkflowBlockHandler` against the loaded child's current field names. Legacy
+ * fields without an id fall back to keying on the name.
+ */
+export function buildCustomBlockConfig(
+ row: CustomBlockRow,
+ inputFields: WorkflowInputField[],
+ opts: { icon: BlockIcon; bgColor?: string }
+): BlockConfig {
+ const fieldSubBlocks: SubBlockConfig[] = inputFields.map((field) => {
+ const type = subBlockTypeForField(field.type)
+ const sub: SubBlockConfig = {
+ id: field.id ?? field.name,
+ title: field.name,
+ type,
+ description: field.description,
+ }
+ if (field.type === 'object' || field.type === 'array') sub.language = 'json'
+ if (field.type === 'file[]') sub.multiple = true
+ return sub
+ })
+
+ return {
+ type: row.type,
+ name: row.name,
+ description: row.description,
+ category: 'tools',
+ longDescription:
+ 'A published workflow packaged as a reusable, self-contained block. Fill its input ' +
+ 'fields; it runs the underlying workflow and returns the outputs below. The bound ' +
+ 'workflow is baked in — no workflow id or input mapping to configure.',
+ bgColor: opts.bgColor ?? CUSTOM_BLOCK_TILE_COLOR,
+ icon: opts.icon,
+ subBlocks: [
+ {
+ id: 'workflowId',
+ type: 'short-input',
+ hidden: true,
+ value: () => row.workflowId,
+ },
+ {
+ id: 'inputMapping',
+ type: 'code',
+ language: 'json',
+ hidden: true,
+ value: (params) => {
+ const mapping: Record = {}
+ for (const [key, val] of Object.entries(params)) {
+ if (RESERVED_PARAMS.has(key)) continue
+ if (val === undefined || val === '') continue
+ mapping[key] = val
+ }
+ return JSON.stringify(mapping)
+ },
+ },
+ ...fieldSubBlocks,
+ ],
+ tools: {
+ access: ['workflow_executor'],
+ config: {
+ tool: () => 'workflow_executor',
+ params: (params) => ({
+ workflowId: params.workflowId,
+ inputMapping: params.inputMapping,
+ }),
+ },
+ },
+ inputs: {
+ workflowId: { type: 'string', description: 'Bound source workflow id' },
+ inputMapping: { type: 'json', description: 'Mapping of input fields to values' },
+ },
+ outputs: buildOutputs(row.exposedOutputs),
+ }
+}
+
+/**
+ * The block's declared outputs. Internal plumbing (child workflow id/name, trace
+ * spans) is never exposed. With curated `exposedOutputs`, each becomes its own
+ * named output; otherwise the whole child `result` is exposed.
+ */
+function buildOutputs(exposed: CustomBlockOutput[] | undefined): BlockConfig['outputs'] {
+ const outputs: BlockConfig['outputs'] = {
+ success: { type: 'boolean', description: 'Execution success status' },
+ error: { type: 'string', description: 'Error message' },
+ }
+ if (exposed && exposed.length > 0) {
+ for (const out of exposed) {
+ outputs[out.name] = { type: 'json', description: `Output: ${out.path}` }
+ }
+ } else {
+ outputs.result = { type: 'json', description: 'Workflow execution result' }
+ }
+ return outputs
+}
diff --git a/apps/sim/blocks/custom/client-overlay.ts b/apps/sim/blocks/custom/client-overlay.ts
new file mode 100644
index 00000000000..cc1bbff01e1
--- /dev/null
+++ b/apps/sim/blocks/custom/client-overlay.ts
@@ -0,0 +1,50 @@
+'use client'
+
+import { useSyncExternalStore } from 'react'
+import { registerBlockOverlayResolver } from '@/blocks/custom/overlay'
+import type { BlockConfig } from '@/blocks/types'
+
+/**
+ * Client-side custom-block overlay: a mutable Map, hydrated from
+ * `useCustomBlocks` by `CustomBlocksProvider`, that the `@/blocks/registry`
+ * accessors fall back to. Scoped to the active workspace's org; re-hydrated on
+ * workspace switch.
+ *
+ * Because many consumers snapshot `getAllBlocks()` (the cmd+K search, the Access
+ * Control block list), the overlay is also an external store: `version` bumps on
+ * every hydrate and listeners are notified, so those consumers can re-read via
+ * {@link useCustomBlockOverlayVersion} instead of going stale until a refresh.
+ */
+let map = new Map()
+let version = 0
+const listeners = new Set<() => void>()
+
+registerBlockOverlayResolver({
+ get: (type) => map.get(type),
+ all: () => [...map.values()],
+})
+
+/** Replace the in-scope custom blocks and notify subscribers. */
+export function hydrateClientCustomBlocks(configs: BlockConfig[]): void {
+ map = new Map(configs.map((config) => [config.type, config]))
+ version += 1
+ for (const listener of listeners) listener()
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+/**
+ * Subscribe a component to overlay changes. Returns a monotonic version that
+ * changes on every hydrate — include it in a `useMemo`/`useEffect` dep list to
+ * recompute anything derived from `getAllBlocks()` when custom blocks load.
+ */
+export function useCustomBlockOverlayVersion(): number {
+ return useSyncExternalStore(
+ subscribe,
+ () => version,
+ () => 0
+ )
+}
diff --git a/apps/sim/blocks/custom/custom-block-icon.tsx b/apps/sim/blocks/custom/custom-block-icon.tsx
new file mode 100644
index 00000000000..08722145fc7
--- /dev/null
+++ b/apps/sim/blocks/custom/custom-block-icon.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { memo, type SVGProps } from 'react'
+import { cn } from '@sim/emcn'
+import { Box } from 'lucide-react'
+import type { BlockIcon } from '@/blocks/types'
+
+/**
+ * Build a `BlockIcon` from an uploaded icon image URL. Rendered as an `
` so
+ * any uploaded PNG/JPEG/SVG works; `className` (size) is forwarded like every
+ * other block icon. Cached by URL so the component reference stays stable across
+ * the many tiles/nodes that render a custom block.
+ */
+const cache = new Map()
+
+export function makeImageIcon(url: string): BlockIcon {
+ const cached = cache.get(url)
+ if (cached) return cached
+
+ const ImageComponent = memo((props: SVGProps) => (
+
+ ))
+ // double-cast-allowed: an
renderer must satisfy the SVG-typed BlockIcon slot
+ const Icon = ImageComponent as unknown as BlockIcon
+
+ cache.set(url, Icon)
+ return Icon
+}
+
+/** Fallback icon for custom blocks published without an uploaded image. */
+// double-cast-allowed: a lucide icon component fills the SVG-typed BlockIcon slot
+export const DefaultCustomBlockIcon: BlockIcon = Box as unknown as BlockIcon
+
+/** Resolve a custom block's icon: the uploaded image when present, else a default glyph. */
+export function getCustomBlockIcon(iconUrl: string | null | undefined): BlockIcon {
+ return iconUrl ? makeImageIcon(iconUrl) : DefaultCustomBlockIcon
+}
diff --git a/apps/sim/blocks/custom/overlay.test.ts b/apps/sim/blocks/custom/overlay.test.ts
new file mode 100644
index 00000000000..366e00cf1f5
--- /dev/null
+++ b/apps/sim/blocks/custom/overlay.test.ts
@@ -0,0 +1,41 @@
+/**
+ * @vitest-environment node
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { buildCustomBlockConfig } from '@/blocks/custom/build-config'
+import {
+ overlayBlocks,
+ registerBlockOverlayResolver,
+ resolveOverlayBlock,
+} from '@/blocks/custom/overlay'
+import type { BlockIcon } from '@/blocks/types'
+
+const icon: BlockIcon = () => null as never
+
+const config = buildCustomBlockConfig(
+ { type: 'custom_block_xyz', name: 'X', description: '', workflowId: 'wf-9' },
+ [],
+ { icon }
+)
+
+afterEach(() => registerBlockOverlayResolver(null))
+
+describe('block overlay resolver', () => {
+ it('returns undefined/empty with no resolver registered', () => {
+ expect(resolveOverlayBlock('custom_block_xyz')).toBeUndefined()
+ expect(overlayBlocks()).toEqual([])
+ })
+
+ it('resolves through a registered resolver and clears on null', () => {
+ const map = new Map([[config.type, config]])
+ registerBlockOverlayResolver({ get: (t) => map.get(t), all: () => [...map.values()] })
+
+ expect(resolveOverlayBlock('custom_block_xyz')).toBe(config)
+ expect(resolveOverlayBlock('nope')).toBeUndefined()
+ expect(overlayBlocks()).toEqual([config])
+
+ registerBlockOverlayResolver(null)
+ expect(resolveOverlayBlock('custom_block_xyz')).toBeUndefined()
+ expect(overlayBlocks()).toEqual([])
+ })
+})
diff --git a/apps/sim/blocks/custom/overlay.ts b/apps/sim/blocks/custom/overlay.ts
new file mode 100644
index 00000000000..64cd02a3c9a
--- /dev/null
+++ b/apps/sim/blocks/custom/overlay.ts
@@ -0,0 +1,36 @@
+import type { BlockConfig } from '@/blocks/types'
+
+/**
+ * Resolver for dynamic (DB-driven) custom blocks that live outside the static
+ * `BLOCK_REGISTRY`. The four core accessors in `@/blocks/registry` fall back to
+ * the registered resolver so custom block types resolve everywhere without
+ * rewriting the many synchronous `getBlock` call sites.
+ *
+ * Two environment-specific resolvers register here:
+ * - client: a Map hydrated from `useCustomBlocks` (see `client-overlay.ts`)
+ * - server: an AsyncLocalStorage map scoped per request/org (see `server-overlay.ts`)
+ *
+ * This module is isomorphic (no `'use client'`, no `node:` imports) so
+ * `registry.ts` stays importable on both sides.
+ */
+export interface BlockOverlayResolver {
+ get(type: string): BlockConfig | undefined
+ all(): BlockConfig[]
+}
+
+let resolver: BlockOverlayResolver | null = null
+
+/** Register (or clear with `null`) the active overlay resolver for this environment. */
+export function registerBlockOverlayResolver(next: BlockOverlayResolver | null): void {
+ resolver = next
+}
+
+/** Resolve a single custom block config by type, or `undefined` when none applies. */
+export function resolveOverlayBlock(type: string): BlockConfig | undefined {
+ return resolver?.get(type)
+}
+
+/** All custom block configs currently in scope (empty when no resolver is active). */
+export function overlayBlocks(): BlockConfig[] {
+ return resolver?.all() ?? []
+}
diff --git a/apps/sim/blocks/custom/server-overlay.ts b/apps/sim/blocks/custom/server-overlay.ts
new file mode 100644
index 00000000000..4f15d495a58
--- /dev/null
+++ b/apps/sim/blocks/custom/server-overlay.ts
@@ -0,0 +1,39 @@
+import { AsyncLocalStorage } from 'node:async_hooks'
+import { buildCustomBlockConfig, type CustomBlockRow } from '@/blocks/custom/build-config'
+import { registerBlockOverlayResolver } from '@/blocks/custom/overlay'
+import type { BlockConfig, BlockIcon } from '@/blocks/types'
+
+/**
+ * Server-side custom-block overlay. Resolves `custom_block_*` types during
+ * serialization + execution from a per-request, per-org map held in
+ * AsyncLocalStorage — keeping the `@/blocks/registry` accessors synchronous while
+ * isolating concurrent requests across different organizations.
+ */
+
+/** Icon is never rendered server-side (the serializer ignores it). */
+const PLACEHOLDER_ICON: BlockIcon = () => null as never
+
+const store = new AsyncLocalStorage