Skip to content

Commit b0cc72e

Browse files
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>
1 parent 7ddd90b commit b0cc72e

14 files changed

Lines changed: 419 additions & 7 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
title: Enrichment
3+
description: Enrich data with a Sim enrichment
4+
---
5+
6+
import { BlockInfoCard } from "@/components/ui/block-info-card"
7+
8+
<BlockInfoCard
9+
type="enrichment"
10+
color="#9333EA"
11+
/>
12+
13+
## Usage Instructions
14+
15+
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.
16+
17+
18+
19+
## Tools
20+
21+
### `enrichment_run`
22+
23+
Run a Sim enrichment (e.g. Work Email, Phone Number) and return its outputs
24+
25+
#### Input
26+
27+
| Parameter | Type | Required | Description |
28+
| --------- | ---- | -------- | ----------- |
29+
| `enrichmentId` | string | Yes | Registry enrichment id \(e.g. "work-email"\) |
30+
| `inputs` | json | Yes | Map of the enrichment's input ids to values |
31+
32+
#### Output
33+
34+
The exact fields depend on which enrichment ran. `matched` and `provider` are always present.
35+
36+
| Parameter | Type | Description |
37+
| --------- | ---- | ----------- |
38+
| `matched` | boolean | Whether the enrichment found a result |
39+
| `provider` | string | Provider whose result was returned (e.g. "Hunter", "People Data Labs"); `null` on no match |
40+
| `email` | string | Work email address (Work Email enrichment) |
41+
| `phone` | string | Phone number (Phone Number enrichment) |
42+
| `domain` | string | Website domain (Company Domain enrichment) |
43+
| `industry` | string | Industry (Company Info enrichment) |
44+
| `employeeCount` | number | Employee count (Company Info enrichment) |
45+
| `foundedYear` | number | Founded year (Company Info enrichment) |
46+
| `description` | string | Company description (Company Info enrichment) |
47+
48+

apps/docs/content/docs/en/tools/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"elevenlabs",
5050
"emailbison",
5151
"enrich",
52+
"enrichment",
5253
"evernote",
5354
"exa",
5455
"extend",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { NextRequest } from 'next/server'
3+
import { NextResponse } from 'next/server'
4+
import { runEnrichmentContract } from '@/lib/api/contracts/tools/enrichment'
5+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { getEnrichment } from '@/enrichments/registry'
9+
import { runEnrichment } from '@/enrichments/run'
10+
11+
const logger = createLogger('EnrichmentRunAPI')
12+
13+
/**
14+
* POST /api/tools/enrichment/run
15+
*
16+
* Runs a registry enrichment's provider cascade and returns its outputs. Backs
17+
* the Enrichment workflow block; called server-to-server by the executor, so it
18+
* authenticates with the internal token. The cascade injects the workspace's
19+
* BYOK / hosted key via `executeTool` using `workspaceId`.
20+
*/
21+
export const POST = withRouteHandler(async (request: NextRequest) => {
22+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
23+
if (!authResult.success) {
24+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
25+
}
26+
27+
const parsed = await parseRequest(
28+
runEnrichmentContract,
29+
request,
30+
{},
31+
{
32+
validationErrorResponse: (error) =>
33+
NextResponse.json(
34+
{ error: getValidationErrorMessage(error, 'Invalid request') },
35+
{
36+
status: 400,
37+
}
38+
),
39+
}
40+
)
41+
if (!parsed.success) return parsed.response
42+
43+
const { enrichmentId, inputs, workspaceId } = parsed.data.body
44+
const enrichment = getEnrichment(enrichmentId)
45+
if (!enrichment) {
46+
return NextResponse.json({ error: `Unknown enrichment "${enrichmentId}"` }, { status: 400 })
47+
}
48+
49+
const { result, cost, error, provider } = await runEnrichment(enrichment, inputs, {
50+
workspaceId,
51+
signal: request.signal,
52+
})
53+
54+
logger.info('Enrichment block run', {
55+
enrichmentId,
56+
matched: Object.keys(result).length > 0,
57+
provider,
58+
})
59+
return NextResponse.json({
60+
matched: Object.keys(result).length > 0,
61+
result,
62+
cost,
63+
error,
64+
provider,
65+
})
66+
})
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { EnrichmentIcon } from '@/components/icons'
2+
import type { BlockConfig, OutputFieldDefinition, ParamType } from '@/blocks/types'
3+
import { IntegrationType } from '@/blocks/types'
4+
import { ALL_ENRICHMENTS, getEnrichment } from '@/enrichments'
5+
import type { EnrichmentInputField, EnrichmentOutputField } from '@/enrichments/types'
6+
import type { EnrichmentRunResponse } from '@/tools/enrichment/types'
7+
8+
/** Maps an enrichment input/output column type to a block field type. */
9+
function fieldType(type: EnrichmentInputField['type'] | EnrichmentOutputField['type']): ParamType {
10+
if (type === 'number') return 'number'
11+
if (type === 'boolean') return 'boolean'
12+
if (type === 'json') return 'json'
13+
return 'string'
14+
}
15+
16+
/** Stable subBlock id for an enrichment input (unique across enrichments). */
17+
const inputFieldId = (enrichmentId: string, inputId: string) => `${enrichmentId}__${inputId}`
18+
19+
// One input field per (enrichment, input), shown only for its enrichment.
20+
const inputSubBlocks = ALL_ENRICHMENTS.flatMap((enrichment) =>
21+
enrichment.inputs.map((input) => ({
22+
id: inputFieldId(enrichment.id, input.id),
23+
title: input.name,
24+
type: 'short-input' as const,
25+
placeholder: input.description ?? `Enter ${input.name.toLowerCase()}`,
26+
condition: { field: 'operation', value: enrichment.id },
27+
required: input.required ? ({ field: 'operation', value: enrichment.id } as const) : undefined,
28+
}))
29+
)
30+
31+
// Block input schema: the operation plus every per-enrichment input field.
32+
const blockInputs: Record<string, { type: ParamType; description: string }> = {
33+
operation: { type: 'string', description: 'Enrichment to run' },
34+
}
35+
for (const enrichment of ALL_ENRICHMENTS) {
36+
for (const input of enrichment.inputs) {
37+
blockInputs[inputFieldId(enrichment.id, input.id)] = {
38+
type: fieldType(input.type),
39+
description: `${input.name} (for ${enrichment.name})`,
40+
}
41+
}
42+
}
43+
44+
// Union of all enrichment outputs, each shown only for the enrichment(s) that
45+
// produce it.
46+
const outputProducers = new Map<string, { field: EnrichmentOutputField; operations: string[] }>()
47+
for (const enrichment of ALL_ENRICHMENTS) {
48+
for (const output of enrichment.outputs) {
49+
const entry = outputProducers.get(output.id) ?? { field: output, operations: [] }
50+
entry.operations.push(enrichment.id)
51+
outputProducers.set(output.id, entry)
52+
}
53+
}
54+
const blockOutputs: Record<string, OutputFieldDefinition> = {
55+
matched: { type: 'boolean', description: 'Whether the enrichment found a result' },
56+
provider: {
57+
type: 'string',
58+
description: 'Provider whose result was returned (e.g. "Hunter", "People Data Labs")',
59+
},
60+
}
61+
for (const [id, { field, operations }] of outputProducers) {
62+
blockOutputs[id] = {
63+
type: fieldType(field.type),
64+
description: field.name,
65+
condition: { field: 'operation', value: operations },
66+
}
67+
}
68+
69+
/**
70+
* Enrichment block — runs a code-defined Sim enrichment (Work Email, Phone
71+
* Number, Company Domain, Company Info, …) and returns its outputs. Generated
72+
* from the enrichment registry, so new enrichments appear automatically. Runs
73+
* on the workspace's hosted / BYOK key (injected server-side); no credential.
74+
*/
75+
export const EnrichmentBlock: BlockConfig<EnrichmentRunResponse> = {
76+
type: 'enrichment',
77+
name: 'Data Enrichment',
78+
description: 'Enrich data with a Sim enrichment',
79+
longDescription:
80+
'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.',
81+
docsLink: 'https://docs.sim.ai/tools/enrichment',
82+
category: 'tools',
83+
integrationType: IntegrationType.Sales,
84+
tags: ['enrichment'],
85+
bgColor: '#9333EA',
86+
icon: EnrichmentIcon,
87+
88+
subBlocks: [
89+
{
90+
id: 'operation',
91+
title: 'Enrichment',
92+
type: 'dropdown',
93+
options: ALL_ENRICHMENTS.map((e) => ({ label: e.name, id: e.id })),
94+
value: () => ALL_ENRICHMENTS[0]?.id ?? '',
95+
},
96+
...inputSubBlocks,
97+
],
98+
99+
tools: {
100+
access: ['enrichment_run'],
101+
config: {
102+
tool: () => 'enrichment_run',
103+
params: (params) => {
104+
const enrichment = getEnrichment(params.operation)
105+
const inputs: Record<string, unknown> = {}
106+
if (enrichment) {
107+
for (const input of enrichment.inputs) {
108+
const value = params[inputFieldId(enrichment.id, input.id)]
109+
if (value !== undefined && value !== '') inputs[input.id] = value
110+
}
111+
}
112+
return { enrichmentId: params.operation, inputs }
113+
},
114+
},
115+
},
116+
117+
inputs: blockInputs,
118+
outputs: blockOutputs,
119+
}

apps/sim/blocks/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { ElasticsearchBlock } from '@/blocks/blocks/elasticsearch'
5151
import { ElevenLabsBlock } from '@/blocks/blocks/elevenlabs'
5252
import { EmailBisonBlock } from '@/blocks/blocks/emailbison'
5353
import { EnrichBlock } from '@/blocks/blocks/enrich'
54+
import { EnrichmentBlock } from '@/blocks/blocks/enrichment'
5455
import { EvaluatorBlock } from '@/blocks/blocks/evaluator'
5556
import { EvernoteBlock } from '@/blocks/blocks/evernote'
5657
import { ExaBlock } from '@/blocks/blocks/exa'
@@ -303,6 +304,7 @@ export const registry: Record<string, BlockConfig> = {
303304
elevenlabs: ElevenLabsBlock,
304305
fathom: FathomBlock,
305306
enrich: EnrichBlock,
307+
enrichment: EnrichmentBlock,
306308
evaluator: EvaluatorBlock,
307309
evernote: EvernoteBlock,
308310
exa: ExaBlock,

apps/sim/components/icons.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import type { SVGProps } from 'react'
22
import { useId } from 'react'
33

4+
export function EnrichmentIcon(props: SVGProps<SVGSVGElement>) {
5+
return (
6+
<svg
7+
{...props}
8+
viewBox='0 0 24 24'
9+
fill='currentColor'
10+
role='img'
11+
xmlns='http://www.w3.org/2000/svg'
12+
>
13+
<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' />
14+
<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' />
15+
<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' />
16+
</svg>
17+
)
18+
}
19+
420
export function AgentMailIcon(props: SVGProps<SVGSVGElement>) {
521
return (
622
<svg {...props} viewBox='0 0 350 363' fill='none' xmlns='http://www.w3.org/2000/svg'>

apps/sim/enrichments/run.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface EnrichmentRunOutcome {
1717
* of blanking it — a genuine "no match" still leaves this `null`.
1818
*/
1919
error: string | null
20+
/** Label of the provider whose result was returned, or `null` on no match. */
21+
provider: string | null
2022
}
2123

2224
/** True when at least one output value in the result is non-empty. */
@@ -77,7 +79,7 @@ export async function runEnrichment(
7779
const result = provider.mapOutput(response.output)
7880
if (result && hasResult(result)) {
7981
logger.info('Enrichment hit', { enrichmentId: enrichment.id, provider: provider.id })
80-
return { result, cost, error: null }
82+
return { result, cost, error: null, provider: provider.label }
8183
}
8284
} catch (err) {
8385
errorCount++
@@ -93,5 +95,5 @@ export async function runEnrichment(
9395
// No provider hit. Surface an error only when every provider that ran errored
9496
// (infra/auth/rate-limit) — a clean miss returns a blank result instead.
9597
const error = ranCount > 0 && errorCount === ranCount ? lastError : null
96-
return { result: {}, cost, error }
98+
return { result: {}, cost, error, provider: null }
9799
}

apps/sim/enrichments/types.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ export interface EnrichmentOutputField {
2121
type: ColumnDefinition['type']
2222
}
2323

24-
/** Per-row execution context handed to a provider's `run()` (runs server-side). */
24+
/**
25+
* Execution context for an enrichment run (runs server-side). `tableId`/`rowId`
26+
* are present for the table per-row path but optional — the workflow block path
27+
* (`/api/tools/enrichment/run`) has no table/row and passes only `workspaceId`.
28+
*/
2529
export interface EnrichmentRunContext {
26-
tableId: string
27-
rowId: string
30+
tableId?: string
31+
rowId?: string
2832
workspaceId: string
2933
signal?: AbortSignal
3034
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { z } from 'zod'
2+
import { defineRouteContract } from '@/lib/api/contracts/types'
3+
4+
export const runEnrichmentBodySchema = z.object({
5+
enrichmentId: z.string().min(1, 'enrichmentId is required'),
6+
/** Per-enrichment input map: enrichment input id → mapped value. */
7+
inputs: z.record(z.string(), z.unknown()).default({}),
8+
workspaceId: z.string().min(1, 'workspaceId is required'),
9+
})
10+
11+
const runEnrichmentResponseSchema = z.object({
12+
matched: z.boolean(),
13+
// untyped-response: per-enrichment output map — keys and value types vary by enrichment
14+
result: z.record(z.string(), z.unknown()),
15+
cost: z.number(),
16+
error: z.string().nullable(),
17+
/** Label of the provider whose result was returned, null on no match. */
18+
provider: z.string().nullable(),
19+
})
20+
21+
export const runEnrichmentContract = defineRouteContract({
22+
method: 'POST',
23+
path: '/api/tools/enrichment/run',
24+
body: runEnrichmentBodySchema,
25+
response: { mode: 'json', schema: runEnrichmentResponseSchema },
26+
})
27+
28+
export type RunEnrichmentBody = z.input<typeof runEnrichmentBodySchema>
29+
export type RunEnrichmentResponse = z.output<typeof runEnrichmentResponseSchema>

apps/sim/tools/enrichment/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { enrichmentRunTool } from './run'

0 commit comments

Comments
 (0)