diff --git a/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts b/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts
new file mode 100644
index 00000000000..11c241986e1
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/[id]/authorize-manage.ts
@@ -0,0 +1,35 @@
+import { NextResponse } from 'next/server'
+import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
+import { getCustomBlockManageContext } from '@/lib/workflows/custom-blocks/operations'
+import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
+
+export type ManageContext = NonNullable>>
+
+/**
+ * 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.
+ */
+export async function authorizeManage(
+ userId: string,
+ id: string
+): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> {
+ const ctx = await getCustomBlockManageContext(id)
+ if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null }
+
+ if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
+ return {
+ error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
+ ctx: null,
+ }
+ }
+ if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
+ return {
+ error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }),
+ ctx: null,
+ }
+ }
+ return { error: null, ctx }
+}
diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts
index 14d7d06ee9f..474d8ea273f 100644
--- a/apps/sim/app/api/custom-blocks/[id]/route.ts
+++ b/apps/sim/app/api/custom-blocks/[id]/route.ts
@@ -9,51 +9,19 @@ import {
} 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,
+ getCustomBlockUsageCounts,
updateCustomBlock,
} from '@/lib/workflows/custom-blocks/operations'
-import { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
+import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage'
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.
- */
-type ManageContext = NonNullable>>
-
-async function authorizeManage(
- userId: string,
- id: string
-): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> {
- const ctx = await getCustomBlockManageContext(id)
- if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null }
-
- if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) {
- return {
- error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }),
- ctx: null,
- }
- }
- if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) {
- return {
- error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }),
- ctx: null,
- }
- }
- return { error: null, ctx }
-}
-
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
const session = await getSession()
if (!session?.user?.id) {
@@ -115,6 +83,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
if (authz.error) return authz.error
const { ctx } = authz
+ const usageCounts = await getCustomBlockUsageCounts(ctx.organizationId, ctx.type)
await deleteCustomBlock(id)
recordAudit({
workspaceId: ctx.sourceWorkspaceId,
@@ -126,7 +95,12 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
resourceId: id,
resourceName: ctx.name,
description: `Unpublished custom block "${ctx.name}"`,
- metadata: { organizationId: ctx.organizationId, type: ctx.type },
+ metadata: {
+ organizationId: ctx.organizationId,
+ type: ctx.type,
+ usageCount: usageCounts.usageCount,
+ deployedUsageCount: usageCounts.deployedUsageCount,
+ },
request,
})
return NextResponse.json({ success: true as const })
diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts
new file mode 100644
index 00000000000..836f7960969
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.test.ts
@@ -0,0 +1,91 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockGetSession, mockIsFeatureEnabled, mockHasWorkspaceAdminAccess, mockOperations } =
+ vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockIsFeatureEnabled: vi.fn(),
+ mockHasWorkspaceAdminAccess: vi.fn(),
+ mockOperations: {
+ getCustomBlockManageContext: vi.fn(),
+ getCustomBlockUsageCounts: vi.fn(),
+ },
+ }))
+
+vi.mock('@/lib/auth', () => ({
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/core/config/feature-flags', () => ({
+ isFeatureEnabled: mockIsFeatureEnabled,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess,
+}))
+
+vi.mock('@/lib/workflows/custom-blocks/operations', () => mockOperations)
+
+import { GET } from '@/app/api/custom-blocks/[id]/usages/route'
+
+const MANAGE_CONTEXT = {
+ organizationId: 'org-1',
+ sourceWorkspaceId: 'ws-1',
+ type: 'custom_block_abc123',
+ name: 'Invoice Parser',
+}
+
+const USAGE_COUNTS = { usageCount: 3, deployedUsageCount: 2 }
+
+function callRoute(id = 'cb-1') {
+ return GET(createMockRequest('GET'), { params: Promise.resolve({ id }) })
+}
+
+describe('GET /api/custom-blocks/[id]/usages', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
+ mockIsFeatureEnabled.mockResolvedValue(true)
+ mockHasWorkspaceAdminAccess.mockResolvedValue(true)
+ mockOperations.getCustomBlockManageContext.mockResolvedValue(MANAGE_CONTEXT)
+ mockOperations.getCustomBlockUsageCounts.mockResolvedValue(USAGE_COUNTS)
+ })
+
+ it('returns 401 without a session', async () => {
+ mockGetSession.mockResolvedValue(null)
+ const response = await callRoute()
+ expect(response.status).toBe(401)
+ })
+
+ it('returns 404 for an unknown block', async () => {
+ mockOperations.getCustomBlockManageContext.mockResolvedValue(null)
+ const response = await callRoute()
+ expect(response.status).toBe(404)
+ })
+
+ it('returns 403 when the feature flag is off', async () => {
+ mockIsFeatureEnabled.mockResolvedValue(false)
+ const response = await callRoute()
+ expect(response.status).toBe(403)
+ })
+
+ it('returns 403 for a non-admin of the source workspace', async () => {
+ mockHasWorkspaceAdminAccess.mockResolvedValue(false)
+ const response = await callRoute()
+ expect(response.status).toBe(403)
+ expect(mockOperations.getCustomBlockUsageCounts).not.toHaveBeenCalled()
+ })
+
+ it('returns the org-scoped usage counts for the block type', async () => {
+ const response = await callRoute()
+ expect(response.status).toBe(200)
+ expect(await response.json()).toEqual(USAGE_COUNTS)
+ expect(mockOperations.getCustomBlockUsageCounts).toHaveBeenCalledWith(
+ 'org-1',
+ 'custom_block_abc123'
+ )
+ })
+})
diff --git a/apps/sim/app/api/custom-blocks/[id]/usages/route.ts b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts
new file mode 100644
index 00000000000..eee59b2c20a
--- /dev/null
+++ b/apps/sim/app/api/custom-blocks/[id]/usages/route.ts
@@ -0,0 +1,26 @@
+import type { NextRequest } from 'next/server'
+import { NextResponse } from 'next/server'
+import { getCustomBlockUsageCountsContract } from '@/lib/api/contracts/custom-blocks'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getCustomBlockUsageCounts } from '@/lib/workflows/custom-blocks/operations'
+import { authorizeManage } from '@/app/api/custom-blocks/[id]/authorize-manage'
+
+type RouteContext = { params: Promise<{ id: string }> }
+
+export const GET = 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(getCustomBlockUsageCountsContract, request, context)
+ if (!parsed.success) return parsed.response
+
+ const authz = await authorizeManage(session.user.id, parsed.data.params.id)
+ if (authz.error) return authz.error
+
+ const counts = await getCustomBlockUsageCounts(authz.ctx.organizationId, authz.ctx.type)
+ return NextResponse.json(counts)
+})
diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts
index d13139aaba7..62cc473e7c9 100644
--- a/apps/sim/blocks/custom/build-config.ts
+++ b/apps/sim/blocks/custom/build-config.ts
@@ -33,6 +33,7 @@ export interface CustomBlockInput {
type: string
placeholder?: string
description?: string
+ required?: boolean
}
/**
@@ -108,6 +109,9 @@ export function buildCustomBlockConfig(
type,
description: field.description,
placeholder: field.placeholder,
+ // Serializer Loop-B (required subBlocks not covered by tool params) and the
+ // editor asterisk both read this — same enforcement path as regular blocks.
+ required: field.required === true,
}
if (field.type === 'object' || field.type === 'array') sub.language = 'json'
if (field.type === 'file[]') sub.multiple = true
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 d9d48a99eed..7eb676229d2 100644
--- a/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
+++ b/apps/sim/ee/custom-blocks/components/custom-block-detail.tsx
@@ -7,6 +7,7 @@ import {
ChipCombobox,
ChipConfirmModal,
ChipInput,
+ ChipModalField,
ChipTextarea,
type ComboboxOptionGroup,
cn,
@@ -14,6 +15,7 @@ import {
ExpandableContent,
Label,
Loader,
+ Switch,
toast,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
@@ -34,6 +36,7 @@ import type { CustomBlockInput, CustomBlockOutput } from '@/blocks/custom/build-
import { SettingRow } from '@/ee/components/setting-row'
import {
useCustomBlocks,
+ useCustomBlockUsageCounts,
useDeleteCustomBlock,
usePublishCustomBlock,
useUpdateCustomBlock,
@@ -138,6 +141,19 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
const [error, setError] = useState(null)
const [showDelete, setShowDelete] = useState(false)
+ // Type-to-confirm buffer for the delete modal — reset whenever the modal opens.
+ const [confirmationText, setConfirmationText] = useState('')
+ const [prevShowDelete, setPrevShowDelete] = useState(false)
+ if (showDelete !== prevShowDelete) {
+ setPrevShowDelete(showDelete)
+ if (showDelete) setConfirmationText('')
+ }
+
+ // Fetched with the view so the count is warm before the delete modal opens.
+ // Server-gated on the same manage authz as delete.
+ const usageCountsQuery = useCustomBlockUsageCounts(existing?.id, { enabled: canManageBlock })
+ const usageCount = usageCountsQuery.data?.usageCount ?? 0
+
// Edit mode may mount before `useCustomBlocks` has resolved this row, leaving the
// buffers empty. Reseed them the first time the block's identity loads (or when it
// changes) — keyed on `existing.id` so a later refetch of the SAME block doesn't
@@ -177,15 +193,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
return m
}, [availableFields])
- const placeholderById = useMemo(() => {
- const m = new Map()
- for (const i of inputs) m.set(i.id, i.placeholder)
+ const overrideById = useMemo(() => {
+ const m = new Map()
+ for (const i of inputs) m.set(i.id, i)
return m
}, [inputs])
// Every deployed Start input is exposed (no selection). Name/type/description are
- // inherited from the field itself (the Start block already defines them); the only
- // thing authored here is the placeholder.
+ // inherited from the field itself (the Start block already defines them); only
+ // the placeholder and required flag are authored here.
const visibleInputs = useMemo(
() =>
deployedLoaded
@@ -196,11 +212,12 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
name: f.name,
type: f.type,
description: f.description,
- placeholder: placeholderById.get(id),
+ placeholder: overrideById.get(id)?.placeholder,
+ required: overrideById.get(id)?.required,
}
})
: inputs,
- [deployedLoaded, availableFields, placeholderById, inputs]
+ [deployedLoaded, availableFields, overrideById, inputs]
)
const [expandedInputs, setExpandedInputs] = useState>(new Set())
@@ -281,15 +298,18 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
deployed.isLoading ||
(deployedLoaded && visibleOutputs.length === 0)
- // Upsert the per-input placeholder (the only authored field). `visibleInputs`
+ // Upsert an authored per-input override (placeholder/required). `visibleInputs`
// shows every deployed field; the first edit of a field adds its override row.
- function setPlaceholder(id: string, placeholder: string) {
+ function setInputOverride(
+ id: string,
+ patch: Partial>
+ ) {
setInputs((prev) => {
if (prev.some((i) => i.id === id)) {
- return prev.map((i) => (i.id === id ? { ...i, placeholder } : i))
+ return prev.map((i) => (i.id === id ? { ...i, ...patch } : i))
}
const f = fieldById.get(id)
- return [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', placeholder }]
+ return [...prev, { id, name: f?.name ?? id, type: f?.type ?? 'string', ...patch }]
})
}
@@ -340,11 +360,15 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
setError('Output names must be unique')
return
}
- // Only the placeholder is authored; the field set/name/type are always derived
- // from the deployed Start. Persist just the non-empty placeholder overrides.
+ // 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
- .filter((i) => i.placeholder?.trim())
- .map((i) => ({ id: i.id, placeholder: i.placeholder!.trim() }))
+ .filter((i) => i.placeholder?.trim() || i.required)
+ .map((i) => ({
+ id: i.id,
+ ...(i.placeholder?.trim() ? { placeholder: i.placeholder.trim() } : {}),
+ ...(i.required ? { required: true } : {}),
+ }))
try {
if (existing) {
@@ -408,7 +432,12 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
{
text: remove.isPending ? 'Deleting...' : 'Delete',
variant: 'destructive' as const,
- onSelect: () => setShowDelete(true),
+ onSelect: () => {
+ setShowDelete(true)
+ // The warning must reflect the org's CURRENT usage, not a
+ // ≤30s-stale cache entry.
+ usageCountsQuery.refetch()
+ },
disabled: remove.isPending,
},
]
@@ -604,11 +633,24 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
)}
+
+
+
+ setInputOverride(i.id, { required: checked })
+ }
+ disabled={!canManageBlock}
+ />
+
setPlaceholder(i.id, e.target.value)}
+ onChange={(e) =>
+ setInputOverride(i.id, { placeholder: e.target.value })
+ }
placeholder='Shown in the empty field'
maxLength={200}
disabled={!canManageBlock}
@@ -686,15 +728,41 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
text={[
'Delete ',
{ text: existing?.name ?? 'this block', bold: true },
- '? Workflows already using it will stop resolving it. This cannot be undone.',
+ '? This cannot be undone.',
]}
confirm={{
label: 'Delete',
onClick: handleDelete,
pending: remove.isPending,
pendingLabel: 'Deleting...',
+ disabled: confirmationText !== (existing?.name ?? ''),
}}
- />
+ >
+ {usageCountsQuery.data &&
+ (usageCount > 0 ? (
+
+ {usageCount} {usageCount === 1 ? 'workflow is' : 'workflows are'} still using this
+ block and will fail at run time.
+
+ ) : (
+
+ No workflows are currently using it.
+
+ ))}
+
+ Type
+ {existing?.name}
+ to confirm
+
+ }
+ value={confirmationText}
+ onChange={setConfirmationText}
+ placeholder={existing?.name}
+ />
+
| undefined
): CustomBlockInput[] {
@@ -723,17 +792,20 @@ function toCustomBlockInputs(
type: f.type,
placeholder: f.placeholder,
description: f.description,
+ required: f.required,
}))
}
/**
- * Compare inputs by only the authored data — the field id and its placeholder.
- * name/type/description are derived live from the deployed Start (not stored), so
- * comparing them would flag the form dirty when only Start metadata drifted.
+ * Compare inputs by only the authored data — the field id, placeholder, and
+ * required flag. name/type/description are derived live from the deployed Start
+ * (not stored), so comparing them would flag the form dirty when only Start
+ * metadata drifted.
*/
function normalizeInputsForCompare(items: ReadonlyArray>) {
return items.map((i) => ({
id: i.id ?? i.name ?? '',
placeholder: i.placeholder ?? '',
+ required: i.required ?? false,
}))
}
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
index ffda0c65a2b..b650cc1059a 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts
@@ -2,6 +2,7 @@ import { setupGlobalFetchMock } from '@sim/testing'
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
import { BlockType } from '@/executor/constants'
import {
+ findMissingRequiredCustomBlockInputs,
remapCustomBlockInputKeys,
WorkflowBlockHandler,
} from '@/executor/handlers/workflow/workflow-handler'
@@ -432,3 +433,67 @@ describe('remapCustomBlockInputKeys', () => {
).toEqual({ payload: 'not json' })
})
})
+
+describe('findMissingRequiredCustomBlockInputs', () => {
+ const childBlocks = {
+ start: {
+ type: 'start_trigger',
+ subBlocks: {
+ inputFormat: {
+ value: [
+ { id: 'f1', name: 'firstName', type: 'string' },
+ { id: 'f2', name: 'payload', type: 'object' },
+ { name: 'legacyField', type: 'string' },
+ ],
+ },
+ },
+ },
+ } as Record
+
+ it('flags a required field left empty and reports its display name', () => {
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, {})).toEqual(['firstName'])
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: '' })).toEqual([
+ 'firstName',
+ ])
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: null })).toEqual([
+ 'firstName',
+ ])
+ })
+
+ it('passes when the required field has a value', () => {
+ expect(
+ findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'Theodore' })
+ ).toEqual([])
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 0 })).toEqual([])
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: false })).toEqual(
+ []
+ )
+ })
+
+ it('ignores a stale required override whose field was removed from the Start', () => {
+ expect(findMissingRequiredCustomBlockInputs(['removed-field'], childBlocks, {})).toEqual([])
+ })
+
+ it('treats fields without an override as optional', () => {
+ expect(findMissingRequiredCustomBlockInputs(['f1'], childBlocks, { firstName: 'x' })).toEqual(
+ []
+ )
+ expect(findMissingRequiredCustomBlockInputs([], childBlocks, {})).toEqual([])
+ })
+
+ it('keys legacy fields without a stable id by name', () => {
+ expect(findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, {})).toEqual([
+ 'legacyField',
+ ])
+ expect(
+ findMissingRequiredCustomBlockInputs(['legacyField'], childBlocks, { legacyField: 'v' })
+ ).toEqual([])
+ })
+
+ it('reports every missing required field at once', () => {
+ expect(findMissingRequiredCustomBlockInputs(['f1', 'f2'], childBlocks, {})).toEqual([
+ 'firstName',
+ 'payload',
+ ])
+ })
+})
diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts
index 70d1e52e089..edde6925eb5 100644
--- a/apps/sim/executor/handlers/workflow/workflow-handler.ts
+++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts
@@ -46,6 +46,41 @@ function getValueAtPath(source: unknown, path: string): unknown {
* name. Legacy fields without an id are keyed by name and pass through unchanged.
* Keys that match no current field are dropped.
*/
+/**
+ * A consumer left a publisher-required custom block input empty. The message is
+ * consumer-safe (it names only the block's own input labels), so the catch's
+ * custom-block sanitizer rethrows it verbatim instead of the generic failure.
+ */
+export class CustomBlockMissingInputsError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'CustomBlockMissingInputsError'
+ }
+}
+
+/**
+ * Names of publisher-required custom block inputs the consumer left empty, checked
+ * against the child's LIVE deployed Start fields — a required override whose field
+ * was removed is inert, and a field added after publish has no override, so schema
+ * drift can never block a run. `childWorkflowInput` is the post-remap mapping
+ * (keyed by field name). Same empty semantics as the serializer's required check.
+ */
+export function findMissingRequiredCustomBlockInputs(
+ requiredInputIds: string[],
+ childBlocks: Record,
+ childWorkflowInput: Record
+): string[] {
+ if (requiredInputIds.length === 0) return []
+ const requiredIds = new Set(requiredInputIds)
+ return extractInputFieldsFromBlocks(childBlocks)
+ .filter((field) => requiredIds.has(field.id ?? field.name))
+ .filter((field) => {
+ const value = childWorkflowInput[field.name]
+ return value === undefined || value === null || value === ''
+ })
+ .map((field) => field.name)
+}
+
export function remapCustomBlockInputKeys(
mapping: Record,
childBlocks: Record
@@ -162,6 +197,7 @@ export class WorkflowBlockHandler implements BlockHandler {
let workflowId = inputs.workflowId
let loadUserId = ctx.userId
let exposedOutputs: CustomBlockOutput[] = []
+ let requiredInputIds: string[] = []
if (isCustomBlock) {
const authority = await getCustomBlockAuthority(blockTypeId as string, ctx.workspaceId)
if (!authority) {
@@ -170,6 +206,7 @@ export class WorkflowBlockHandler implements BlockHandler {
workflowId = authority.workflowId
loadUserId = authority.ownerUserId
exposedOutputs = authority.exposedOutputs
+ requiredInputIds = authority.requiredInputIds
}
if (!workflowId) {
@@ -270,6 +307,19 @@ export class WorkflowBlockHandler implements BlockHandler {
childWorkflowInput = inputs.input
}
+ if (isCustomBlock) {
+ const missing = findMissingRequiredCustomBlockInputs(
+ requiredInputIds,
+ childWorkflow.rawBlocks || {},
+ childWorkflowInput
+ )
+ if (missing.length > 0) {
+ throw new CustomBlockMissingInputsError(
+ `${block.metadata?.name || 'Custom block'} is missing required fields: ${missing.join(', ')}`
+ )
+ }
+ }
+
const childSnapshotResult = await snapshotService.createSnapshotWithDeduplication(
workflowId,
childWorkflow.workflowState
@@ -406,6 +456,16 @@ export class WorkflowBlockHandler implements BlockHandler {
// so capture the child's spans server-side, distill to the aggregate cost, and
// carry only that (no internals) so `block-executor` still bills it.
if (isCustomBlock) {
+ // Missing-required-inputs is the consumer's own mistake and its message
+ // names only the block's input labels — surface it instead of the generic
+ // failure so they can actually fix it. The child never ran: no spend.
+ if (error instanceof CustomBlockMissingInputsError) {
+ throw new ChildWorkflowError({
+ message: error.message,
+ childWorkflowName: block.metadata?.name || 'Custom block',
+ childWorkflowInstanceId: instanceId,
+ })
+ }
let failedChildSpans: WorkflowTraceSpan[] = []
if (hasExecutionResult(error) && error.executionResult.logs) {
failedChildSpans = this.captureChildWorkflowLogs(
diff --git a/apps/sim/hooks/queries/custom-blocks.ts b/apps/sim/hooks/queries/custom-blocks.ts
index 5ab6de8e3b7..55a127fc68f 100644
--- a/apps/sim/hooks/queries/custom-blocks.ts
+++ b/apps/sim/hooks/queries/custom-blocks.ts
@@ -2,7 +2,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
type CustomBlock,
+ type CustomBlockUsageCounts,
deleteCustomBlockContract,
+ getCustomBlockUsageCountsContract,
listCustomBlocksContract,
type PublishCustomBlockBody,
publishCustomBlockContract,
@@ -11,11 +13,14 @@ import {
} from '@/lib/api/contracts/custom-blocks'
export const CUSTOM_BLOCK_LIST_STALE_TIME = 60 * 1000
+/** Short — the usage count is a pre-delete safety check and must stay fresh. */
+export const CUSTOM_BLOCK_USAGES_STALE_TIME = 30 * 1000
export const customBlockKeys = {
all: ['custom-blocks'] as const,
lists: () => [...customBlockKeys.all, 'list'] as const,
list: (workspaceId?: string) => [...customBlockKeys.lists(), workspaceId ?? ''] as const,
+ usages: (id?: string) => [...customBlockKeys.all, 'usages', id ?? ''] as const,
}
interface CustomBlocksResult {
@@ -53,6 +58,23 @@ export function useCanPublishCustomBlock(workspaceId?: string) {
return useCustomBlocksQuery(workspaceId, (r) => r.enabled)
}
+function fetchCustomBlockUsageCounts(
+ id: string,
+ signal?: AbortSignal
+): Promise {
+ return requestJson(getCustomBlockUsageCountsContract, { params: { id }, signal })
+}
+
+/** How many workflows across the org place this block (live editor state and/or active deployment). */
+export function useCustomBlockUsageCounts(blockId?: string, options?: { enabled?: boolean }) {
+ return useQuery({
+ queryKey: customBlockKeys.usages(blockId),
+ queryFn: ({ signal }) => fetchCustomBlockUsageCounts(blockId as string, signal),
+ enabled: Boolean(blockId) && (options?.enabled ?? true),
+ staleTime: CUSTOM_BLOCK_USAGES_STALE_TIME,
+ })
+}
+
export function usePublishCustomBlock(workspaceId?: string) {
const queryClient = useQueryClient()
return useMutation({
diff --git a/apps/sim/lib/api/contracts/custom-blocks.ts b/apps/sim/lib/api/contracts/custom-blocks.ts
index 95d0915bca2..01adbbddfc0 100644
--- a/apps/sim/lib/api/contracts/custom-blocks.ts
+++ b/apps/sim/lib/api/contracts/custom-blocks.ts
@@ -11,17 +11,20 @@ const inputFieldSchema = z.object({
description: z.string().optional(),
/** Consumer-facing placeholder hint (curated inputs only). */
placeholder: z.string().optional(),
+ /** Consumers must fill this input (curated inputs only). */
+ required: z.boolean().optional(),
})
/**
- * The only authored per-input datum: a placeholder, keyed by the source Start
- * field's stable `id`. The field's name/type/description are NOT stored — they're
- * always derived from the live deployed Start (so they can't go stale), and this
- * map only supplies the consumer-facing placeholder hint.
+ * The authored per-input data: a placeholder and a required flag, keyed by the
+ * source Start field's stable `id`. The field's name/type/description are NOT
+ * stored — they're always derived from the live deployed Start (so they can't go
+ * stale); an override whose field was removed from the Start is silently ignored.
*/
const inputPlaceholderSchema = z.object({
id: z.string().min(1),
placeholder: z.string().max(200).optional(),
+ required: z.boolean().optional(),
})
export type CustomBlockInputPlaceholder = z.input
@@ -93,6 +96,18 @@ export const updateCustomBlockBodySchema = z
export type UpdateCustomBlockBody = z.input
+/**
+ * How many workflows in the org place this block. Live editor state and the
+ * active deployment snapshot can diverge, so a workflow counts when the block
+ * appears in either; `deployedUsageCount` counts active deployments only.
+ */
+export const customBlockUsageCountsSchema = z.object({
+ usageCount: z.number().int().min(0),
+ deployedUsageCount: z.number().int().min(0),
+})
+
+export type CustomBlockUsageCounts = z.output
+
export const listCustomBlocksContract = defineRouteContract({
method: 'GET',
path: '/api/custom-blocks',
@@ -137,3 +152,13 @@ export const deleteCustomBlockContract = defineRouteContract({
schema: z.object({ success: z.literal(true) }),
},
})
+
+export const getCustomBlockUsageCountsContract = defineRouteContract({
+ method: 'GET',
+ path: '/api/custom-blocks/[id]/usages',
+ params: customBlockIdParamsSchema,
+ response: {
+ mode: 'json',
+ schema: customBlockUsageCountsSchema,
+ },
+})
diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts
index 28d7dc5af35..23e602b83d1 100644
--- a/apps/sim/lib/workflows/custom-blocks/operations.ts
+++ b/apps/sim/lib/workflows/custom-blocks/operations.ts
@@ -1,8 +1,14 @@
import { db } from '@sim/db'
-import { customBlock, workflow, workspace } from '@sim/db/schema'
+import {
+ customBlock,
+ workflow,
+ workflowBlocks,
+ workflowDeploymentVersion,
+ workspace,
+} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId, generateShortId } from '@sim/utils/id'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, isNull, sql } from 'drizzle-orm'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { extractInputFieldsFromBlocks, type WorkflowInputField } from '@/lib/workflows/input-format'
@@ -62,15 +68,15 @@ async function deriveInputFields(workflowId: string): Promise [p.id, p.placeholder]))
- // Placeholders are stored under `field.id ?? field.name` (the form's key), so a
+ const byId = new Map(placeholders.map((p) => [p.id, p]))
+ // Overrides are stored under `field.id ?? field.name` (the form's key), so a
// legacy field with no stable id is keyed by name — look it up the same way.
return deployed.map((field) => {
- const placeholder = byId.get(field.id ?? field.name)
- return placeholder ? { ...field, placeholder } : field
+ const override = byId.get(field.id ?? field.name)
+ if (!override) return field
+ return {
+ ...field,
+ ...(override.placeholder ? { placeholder: override.placeholder } : {}),
+ ...(override.required ? { required: true } : {}),
+ }
})
}
@@ -250,6 +261,8 @@ export async function getCustomBlockAuthority(
organizationId: string
ownerUserId: string
exposedOutputs: CustomBlockOutput[]
+ /** Start-field ids (form keys) the publisher marked required. May reference removed fields. */
+ requiredInputIds: string[]
} | null> {
// Scope resolution to the consumer's org: `(organizationId, type)` is the unique
// key, so without the org filter a `custom_block_*` type smuggled in from another
@@ -267,6 +280,7 @@ export async function getCustomBlockAuthority(
organizationId: customBlock.organizationId,
enabled: customBlock.enabled,
outputs: customBlock.outputs,
+ inputs: customBlock.inputs,
ownerUserId: workflow.userId,
})
.from(customBlock)
@@ -282,6 +296,7 @@ export async function getCustomBlockAuthority(
organizationId: row.organizationId,
ownerUserId: row.ownerUserId,
exposedOutputs: row.outputs ?? [],
+ requiredInputIds: (row.inputs ?? []).filter((i) => i.required).map((i) => i.id),
}
}
@@ -432,3 +447,55 @@ export async function updateCustomBlock(
export async function deleteCustomBlock(id: string): Promise {
await db.delete(customBlock).where(eq(customBlock.id, id))
}
+
+/**
+ * How many non-archived workflows in the org place the block, in their live
+ * editor state and/or their ACTIVE deployment snapshot. The two are scanned
+ * independently — a block removed in the editor can still ship in the active
+ * deployment (and vice versa), and the deployed placement is the one that
+ * actually runs. The deployment scan pre-filters with a raw-text match on the
+ * unique type slug so only near-exact matches pay the jsonb parse.
+ */
+export async function getCustomBlockUsageCounts(
+ organizationId: string,
+ blockType: string
+): Promise<{ usageCount: number; deployedUsageCount: number }> {
+ const orgActiveWorkflow = and(
+ eq(workspace.organizationId, organizationId),
+ isNull(workflow.archivedAt)
+ )
+ // Escape LIKE wildcards — the `_`s in `custom_block_` would otherwise match
+ // any character and let unrelated states through to the jsonb parse.
+ const likePattern = `%${blockType.replace(/[\\%_]/g, '\\$&')}%`
+
+ const [liveRows, deployedRows] = await Promise.all([
+ db
+ .selectDistinct({ workflowId: workflow.id })
+ .from(workflowBlocks)
+ .innerJoin(workflow, eq(workflow.id, workflowBlocks.workflowId))
+ .innerJoin(workspace, eq(workspace.id, workflow.workspaceId))
+ .where(and(eq(workflowBlocks.type, blockType), orgActiveWorkflow)),
+ db
+ .select({ workflowId: workflow.id })
+ .from(workflowDeploymentVersion)
+ .innerJoin(workflow, eq(workflow.id, workflowDeploymentVersion.workflowId))
+ .innerJoin(workspace, eq(workspace.id, workflow.workspaceId))
+ .where(
+ and(
+ eq(workflowDeploymentVersion.isActive, true),
+ eq(workflow.isDeployed, true),
+ orgActiveWorkflow,
+ sql`${workflowDeploymentVersion.state}::text LIKE ${likePattern} ESCAPE '\\'`,
+ sql`EXISTS (
+ SELECT 1 FROM jsonb_each((${workflowDeploymentVersion.state})::jsonb -> 'blocks') AS b
+ WHERE b.value ->> 'type' = ${blockType}
+ )`
+ )
+ ),
+ ])
+
+ const usingWorkflowIds = new Set(liveRows.map((r) => r.workflowId))
+ for (const row of deployedRows) usingWorkflowIds.add(row.workflowId)
+
+ return { usageCount: usingWorkflowIds.size, deployedUsageCount: deployedRows.length }
+}
diff --git a/apps/sim/lib/workflows/input-format.ts b/apps/sim/lib/workflows/input-format.ts
index 97a63bc7e96..1f73780f65d 100644
--- a/apps/sim/lib/workflows/input-format.ts
+++ b/apps/sim/lib/workflows/input-format.ts
@@ -22,6 +22,11 @@ export interface WorkflowInputField {
* in the Custom Blocks settings UI; has no source on the workflow's Start block.
*/
placeholder?: string
+ /**
+ * Consumers must fill this custom-block input. Authored in the Custom Blocks
+ * settings UI; has no source on the workflow's Start block.
+ */
+ required?: boolean
}
/**
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 8028d3a3a10..d18762a071e 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -2893,13 +2893,14 @@ export const customBlock = pgTable(
/** Uploaded icon image URL (workspace storage), or null for the default icon. */
iconUrl: text('icon_url'),
/**
- * Per-input placeholder hints keyed by the source Start field's stable `id`:
- * `Array<{ id, placeholder? }>`. Only the placeholder is authored — the input
- * field set and its name/type/description are always derived live from the
- * deployed Start (so they can never go stale). Absent/empty → no placeholder
- * overrides; every deployed Start input is still exposed.
+ * Per-input authored overrides keyed by the source Start field's stable `id`:
+ * `Array<{ id, placeholder?, required? }>`. Only the placeholder and required
+ * flag are authored — the input field set and its name/type/description are
+ * always derived live from the deployed Start (so they can never go stale); an
+ * override whose field was removed is ignored. Absent/empty → no overrides;
+ * every deployed Start input is still exposed.
*/
- inputs: json('inputs').$type>(),
+ inputs: json('inputs').$type>(),
/**
* Curated outputs exposed to consumers: `Array<{ blockId, path, name }>`. Each
* maps a child-workflow block output (blockId + dot-path) to a friendly output
diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts
index 9b54931dd94..d8694b3f6bc 100644
--- a/scripts/check-api-validation-contracts.ts
+++ b/scripts/check-api-validation-contracts.ts
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
const BASELINE = {
- totalRoutes: 926,
- zodRoutes: 926,
+ totalRoutes: 927,
+ zodRoutes: 927,
nonZodRoutes: 0,
} as const