feat(block): Add data enrichment block (#4774)

* feat(enrichment): workflow Enrichment block + /api/tools/enrichment/run

Add a generic Enrichment workflow block that runs a code-defined enrichment
(Work Email, Phone Number, Company Domain, Company Info, …) and returns its
outputs — usable in workflows, not just tables.

- New internal endpoint POST /api/tools/enrichment/run (checkInternalAuth +
  contract) runs the same runEnrichment provider cascade; injects the
  workspace's hosted/BYOK key via executeTool.
- New tool enrichment_run posts to it and surfaces hosted-key cost on the
  output so the workflow logging session bills it.
- New block blocks/blocks/enrichment.ts generated from the enrichment registry:
  operation dropdown = enrichments, per-enrichment conditional inputs, union of
  conditional outputs. New registry entries appear automatically.
- EnrichmentRunContext.tableId/rowId made optional (workflow path has no row).
- Register tool + block; bump api-validation route baseline; add EnrichmentIcon
  and generated docs page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: re-trigger CI

* refactor(enrichment): share mapFieldType helper between block and tool

* fix(enrichment): reserved output keys (matched/provider) win over enrichment outputs

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Theodore Li
2026-05-28 22:19:08 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent f2c17d8072
commit 7f24ae12fd
15 changed files with 427 additions and 8 deletions
@@ -0,0 +1,48 @@
---
title: Enrichment
description: Enrich data with a Sim enrichment
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="enrichment"
color="#9333EA"
/>
## Usage Instructions
Run a Sim enrichment to look up data — work email, phone number, company domain, company info, and more — from the fields you map in. Uses the same provider cascade as table enrichments.
## Tools
### `enrichment_run`
Run a Sim enrichment (e.g. Work Email, Phone Number) and return its outputs
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `enrichmentId` | string | Yes | Registry enrichment id \(e.g. "work-email"\) |
| `inputs` | json | Yes | Map of the enrichment's input ids to values |
#### Output
The exact fields depend on which enrichment ran. `matched` and `provider` are always present.
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `matched` | boolean | Whether the enrichment found a result |
| `provider` | string | Provider whose result was returned (e.g. "Hunter", "People Data Labs"); `null` on no match |
| `email` | string | Work email address (Work Email enrichment) |
| `phone` | string | Phone number (Phone Number enrichment) |
| `domain` | string | Website domain (Company Domain enrichment) |
| `industry` | string | Industry (Company Info enrichment) |
| `employeeCount` | number | Employee count (Company Info enrichment) |
| `foundedYear` | number | Founded year (Company Info enrichment) |
| `description` | string | Company description (Company Info enrichment) |
@@ -49,6 +49,7 @@
"elevenlabs",
"emailbison",
"enrich",
"enrichment",
"evernote",
"exa",
"extend",
@@ -0,0 +1,66 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { runEnrichmentContract } from '@/lib/api/contracts/tools/enrichment'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getEnrichment } from '@/enrichments/registry'
import { runEnrichment } from '@/enrichments/run'
const logger = createLogger('EnrichmentRunAPI')
/**
* POST /api/tools/enrichment/run
*
* Runs a registry enrichment's provider cascade and returns its outputs. Backs
* the Enrichment workflow block; called server-to-server by the executor, so it
* authenticates with the internal token. The cascade injects the workspace's
* BYOK / hosted key via `executeTool` using `workspaceId`.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest(
runEnrichmentContract,
request,
{},
{
validationErrorResponse: (error) =>
NextResponse.json(
{ error: getValidationErrorMessage(error, 'Invalid request') },
{
status: 400,
}
),
}
)
if (!parsed.success) return parsed.response
const { enrichmentId, inputs, workspaceId } = parsed.data.body
const enrichment = getEnrichment(enrichmentId)
if (!enrichment) {
return NextResponse.json({ error: `Unknown enrichment "${enrichmentId}"` }, { status: 400 })
}
const { result, cost, error, provider } = await runEnrichment(enrichment, inputs, {
workspaceId,
signal: request.signal,
})
logger.info('Enrichment block run', {
enrichmentId,
matched: Object.keys(result).length > 0,
provider,
})
return NextResponse.json({
matched: Object.keys(result).length > 0,
result,
cost,
error,
provider,
})
})
+117
View File
@@ -0,0 +1,117 @@
import { EnrichmentIcon } from '@/components/icons'
import type { BlockConfig, OutputFieldDefinition, ParamType } from '@/blocks/types'
import { IntegrationType } from '@/blocks/types'
import { ALL_ENRICHMENTS, getEnrichment } from '@/enrichments'
import { mapFieldType } from '@/enrichments/providers'
import type { EnrichmentOutputField } from '@/enrichments/types'
import type { EnrichmentRunResponse } from '@/tools/enrichment/types'
/** Stable subBlock id for an enrichment input (unique across enrichments). */
const inputFieldId = (enrichmentId: string, inputId: string) => `${enrichmentId}__${inputId}`
// One input field per (enrichment, input), shown only for its enrichment.
const inputSubBlocks = ALL_ENRICHMENTS.flatMap((enrichment) =>
enrichment.inputs.map((input) => ({
id: inputFieldId(enrichment.id, input.id),
title: input.name,
type: 'short-input' as const,
placeholder: input.description ?? `Enter ${input.name.toLowerCase()}`,
condition: { field: 'operation', value: enrichment.id },
required: input.required ? ({ field: 'operation', value: enrichment.id } as const) : undefined,
}))
)
// Block input schema: the operation plus every per-enrichment input field.
const blockInputs: Record<string, { type: ParamType; description: string }> = {
operation: { type: 'string', description: 'Enrichment to run' },
}
for (const enrichment of ALL_ENRICHMENTS) {
for (const input of enrichment.inputs) {
blockInputs[inputFieldId(enrichment.id, input.id)] = {
type: mapFieldType(input.type),
description: `${input.name} (for ${enrichment.name})`,
}
}
}
// Union of all enrichment outputs, each shown only for the enrichment(s) that
// produce it.
const outputProducers = new Map<string, { field: EnrichmentOutputField; operations: string[] }>()
for (const enrichment of ALL_ENRICHMENTS) {
for (const output of enrichment.outputs) {
const entry = outputProducers.get(output.id) ?? { field: output, operations: [] }
entry.operations.push(enrichment.id)
outputProducers.set(output.id, entry)
}
}
// Seed the enrichment outputs first so the reserved `matched` / `provider`
// keys (assigned below) always win if a future enrichment ever declares an
// output id that collides with them.
const blockOutputs: Record<string, OutputFieldDefinition> = {}
for (const [id, { field, operations }] of outputProducers) {
blockOutputs[id] = {
type: mapFieldType(field.type),
description: field.name,
condition: { field: 'operation', value: operations },
}
}
blockOutputs.matched = {
type: 'boolean',
description: 'Whether the enrichment found a result',
}
blockOutputs.provider = {
type: 'string',
description: 'Provider whose result was returned (e.g. "Hunter", "People Data Labs")',
}
/**
* Enrichment block — runs a code-defined Sim enrichment (Work Email, Phone
* Number, Company Domain, Company Info, …) and returns its outputs. Generated
* from the enrichment registry, so new enrichments appear automatically. Runs
* on the workspace's hosted / BYOK key (injected server-side); no credential.
*/
export const EnrichmentBlock: BlockConfig<EnrichmentRunResponse> = {
type: 'enrichment',
name: 'Data Enrichment',
description: 'Enrich data with a Sim enrichment',
longDescription:
'Run a Sim enrichment to look up data — work email, phone number, company domain, company info, and more — from the fields you map in. Uses the same provider cascade as table enrichments.',
docsLink: 'https://docs.sim.ai/tools/enrichment',
category: 'tools',
integrationType: IntegrationType.Sales,
tags: ['enrichment'],
bgColor: '#9333EA',
icon: EnrichmentIcon,
subBlocks: [
{
id: 'operation',
title: 'Enrichment',
type: 'dropdown',
options: ALL_ENRICHMENTS.map((e) => ({ label: e.name, id: e.id })),
value: () => ALL_ENRICHMENTS[0]?.id ?? '',
},
...inputSubBlocks,
],
tools: {
access: ['enrichment_run'],
config: {
tool: () => 'enrichment_run',
params: (params) => {
const enrichment = getEnrichment(params.operation)
const inputs: Record<string, unknown> = {}
if (enrichment) {
for (const input of enrichment.inputs) {
const value = params[inputFieldId(enrichment.id, input.id)]
if (value !== undefined && value !== '') inputs[input.id] = value
}
}
return { enrichmentId: params.operation, inputs }
},
},
},
inputs: blockInputs,
outputs: blockOutputs,
}
+2
View File
@@ -51,6 +51,7 @@ import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
import { EmailBisonBlock } from '@/blocks/blocks/emailbison'
import { EnrichBlock } from '@/blocks/blocks/enrich'
import { EnrichmentBlock } from '@/blocks/blocks/enrichment'
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
import { EvernoteBlock } from '@/blocks/blocks/evernote'
import { ExaBlock } from '@/blocks/blocks/exa'
@@ -305,6 +306,7 @@ export const registry: Record<string, BlockConfig> = {
elevenlabs: ElevenLabsBlock,
fathom: FathomBlock,
enrich: EnrichBlock,
enrichment: EnrichmentBlock,
evaluator: EvaluatorBlock,
evernote: EvernoteBlock,
exa: ExaBlock,
+16
View File
@@ -1,6 +1,22 @@
import type { SVGProps } from 'react'
import { useId } from 'react'
export function EnrichmentIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
viewBox='0 0 24 24'
fill='currentColor'
role='img'
xmlns='http://www.w3.org/2000/svg'
>
<path d='M12 2.5l1.9 4.6 4.6 1.9-4.6 1.9L12 15.5l-1.9-4.6L5.5 9l4.6-1.9L12 2.5z' />
<path d='M18.5 14l.95 2.3 2.3.95-2.3.95L18.5 20.5l-.95-2.3-2.3-.95 2.3-.95.95-2.3z' />
<path d='M5.5 14.5l.7 1.7 1.7.7-1.7.7-.7 1.7-.7-1.7-1.7-.7 1.7-.7.7-1.7z' />
</svg>
)
}
export function AgentMailIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} viewBox='0 0 350 363' fill='none' xmlns='http://www.w3.org/2000/svg'>
+22 -1
View File
@@ -1,4 +1,25 @@
import type { EnrichmentProvider } from '@/enrichments/types'
import type {
EnrichmentInputField,
EnrichmentOutputField,
EnrichmentProvider,
} from '@/enrichments/types'
/**
* Narrow union of the field types enrichments declare. Assignable to both the
* tool `OutputType` and the block `ParamType` unions, so a single mapping
* function feeds both sides without per-call casts.
*/
export type EnrichmentFieldType = 'string' | 'number' | 'boolean' | 'json'
/** Maps an enrichment input/output column type to a block/tool field type. */
export function mapFieldType(
type: EnrichmentInputField['type'] | EnrichmentOutputField['type']
): EnrichmentFieldType {
if (type === 'number') return 'number'
if (type === 'boolean') return 'boolean'
if (type === 'json') return 'json'
return 'string'
}
/** Coerces an unknown input value to a trimmed string (`''` when nullish). */
export function str(value: unknown): string {
+4 -2
View File
@@ -17,6 +17,8 @@ export interface EnrichmentRunOutcome {
* of blanking it — a genuine "no match" still leaves this `null`.
*/
error: string | null
/** Label of the provider whose result was returned, or `null` on no match. */
provider: string | null
}
/** True when at least one output value in the result is non-empty. */
@@ -77,7 +79,7 @@ export async function runEnrichment(
const result = provider.mapOutput(response.output)
if (result && hasResult(result)) {
logger.info('Enrichment hit', { enrichmentId: enrichment.id, provider: provider.id })
return { result, cost, error: null }
return { result, cost, error: null, provider: provider.label }
}
} catch (err) {
errorCount++
@@ -93,5 +95,5 @@ export async function runEnrichment(
// No provider hit. Surface an error only when every provider that ran errored
// (infra/auth/rate-limit) — a clean miss returns a blank result instead.
const error = ranCount > 0 && errorCount === ranCount ? lastError : null
return { result: {}, cost, error }
return { result: {}, cost, error, provider: null }
}
+7 -3
View File
@@ -21,10 +21,14 @@ export interface EnrichmentOutputField {
type: ColumnDefinition['type']
}
/** Per-row execution context handed to a provider's `run()` (runs server-side). */
/**
* Execution context for an enrichment run (runs server-side). `tableId`/`rowId`
* are present for the table per-row path but optional — the workflow block path
* (`/api/tools/enrichment/run`) has no table/row and passes only `workspaceId`.
*/
export interface EnrichmentRunContext {
tableId: string
rowId: string
tableId?: string
rowId?: string
workspaceId: string
signal?: AbortSignal
}
@@ -0,0 +1,29 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const runEnrichmentBodySchema = z.object({
enrichmentId: z.string().min(1, 'enrichmentId is required'),
/** Per-enrichment input map: enrichment input id → mapped value. */
inputs: z.record(z.string(), z.unknown()).default({}),
workspaceId: z.string().min(1, 'workspaceId is required'),
})
const runEnrichmentResponseSchema = z.object({
matched: z.boolean(),
// untyped-response: per-enrichment output map — keys and value types vary by enrichment
result: z.record(z.string(), z.unknown()),
cost: z.number(),
error: z.string().nullable(),
/** Label of the provider whose result was returned, null on no match. */
provider: z.string().nullable(),
})
export const runEnrichmentContract = defineRouteContract({
method: 'POST',
path: '/api/tools/enrichment/run',
body: runEnrichmentBodySchema,
response: { mode: 'json', schema: runEnrichmentResponseSchema },
})
export type RunEnrichmentBody = z.input<typeof runEnrichmentBodySchema>
export type RunEnrichmentResponse = z.output<typeof runEnrichmentResponseSchema>
+1
View File
@@ -0,0 +1 @@
export { enrichmentRunTool } from './run'
+93
View File
@@ -0,0 +1,93 @@
import { ALL_ENRICHMENTS } from '@/enrichments'
import { mapFieldType } from '@/enrichments/providers'
import type { EnrichmentRunParams, EnrichmentRunResponse } from '@/tools/enrichment/types'
import type { OutputProperty, ToolConfig } from '@/tools/types'
/** Union of every distinct output across all registry enrichments. */
const enrichmentOutputs: Record<string, OutputProperty> = {}
for (const enrichment of ALL_ENRICHMENTS) {
for (const output of enrichment.outputs) {
if (!enrichmentOutputs[output.id]) {
enrichmentOutputs[output.id] = {
type: mapFieldType(output.type),
description: `${output.name} (from the selected enrichment)`,
optional: true,
}
}
}
}
/**
* Runs a registry enrichment via `/api/tools/enrichment/run`. Selected and fed
* by the Enrichment block; the route runs the provider cascade with the
* workspace's hosted / BYOK key.
*/
export const enrichmentRunTool: ToolConfig<EnrichmentRunParams, EnrichmentRunResponse> = {
id: 'enrichment_run',
name: 'Run Enrichment',
description: 'Run a Sim enrichment (e.g. Work Email, Phone Number) and return its outputs',
version: '1.0.0',
params: {
enrichmentId: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Registry enrichment id (e.g. "work-email")',
},
inputs: {
type: 'json',
required: true,
visibility: 'user-or-llm',
description: "Map of the enrichment's input ids to values",
},
},
request: {
url: '/api/tools/enrichment/run',
method: 'POST',
headers: () => ({ 'Content-Type': 'application/json' }),
body: (params: EnrichmentRunParams & { _context?: { workspaceId?: string } }) => ({
enrichmentId: params.enrichmentId,
inputs: params.inputs ?? {},
workspaceId: params._context?.workspaceId,
}),
},
transformResponse: async (response: Response) => {
const data = await response.json()
if (!response.ok || data.error) {
return {
success: false,
output: { matched: false, provider: null },
error: data.error || `Enrichment failed (${response.status})`,
}
}
const result = (data.result ?? {}) as Record<string, unknown>
const cost = typeof data.cost === 'number' ? data.cost : 0
const provider = typeof data.provider === 'string' ? data.provider : null
return {
success: true,
output: {
...result,
matched: Boolean(data.matched),
provider,
// Surface hosted-key cost so the workflow logging session bills it,
// matching the convention used by hosted-key tools.
...(cost > 0 ? { cost: { total: cost } } : {}),
},
}
},
// Reserved keys go LAST so they always win if an enrichment ever declares an
// output id of `matched` or `provider` (later spread / assignment wins in JS).
outputs: {
...enrichmentOutputs,
matched: { type: 'boolean', description: 'Whether the enrichment found a result' },
provider: {
type: 'string',
description: 'Provider whose result was returned (e.g. "Hunter", "People Data Labs")',
optional: true,
},
},
}
+17
View File
@@ -0,0 +1,17 @@
import type { ToolResponse } from '@/tools/types'
export interface EnrichmentRunParams {
/** Registry enrichment id (e.g. `work-email`). */
enrichmentId: string
/** Map of the enrichment's input ids → values. */
inputs: Record<string, unknown>
}
export interface EnrichmentRunResponse extends ToolResponse {
output: {
/** Whether the enrichment found a result. */
matched: boolean
/** Label of the provider whose result was returned, null on no match. */
provider: string | null
} & Record<string, unknown>
}
+2
View File
@@ -633,6 +633,7 @@ import {
enrichSearchSimilarCompaniesTool,
enrichVerifyEmailTool,
} from '@/tools/enrich'
import { enrichmentRunTool } from '@/tools/enrichment'
import {
evernoteCopyNoteTool,
evernoteCreateNotebookTool,
@@ -4363,6 +4364,7 @@ export const tools: Record<string, ToolConfig> = {
enrich_search_posts: enrichSearchPostsTool,
enrich_search_similar_companies: enrichSearchSimilarCompaniesTool,
enrich_verify_email: enrichVerifyEmailTool,
enrichment_run: enrichmentRunTool,
extend_parser: extendParserTool,
extend_parser_v2: extendParserV2Tool,
exa_search: exaSearchTool,
+2 -2
View File
@@ -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: 757,
zodRoutes: 757,
totalRoutes: 758,
zodRoutes: 758,
nonZodRoutes: 0,
} as const