Skip to content

Commit 7f24ae1

Browse files
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>
1 parent f2c17d8 commit 7f24ae1

15 files changed

Lines changed: 427 additions & 8 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: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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 { mapFieldType } from '@/enrichments/providers'
6+
import type { EnrichmentOutputField } from '@/enrichments/types'
7+
import type { EnrichmentRunResponse } from '@/tools/enrichment/types'
8+
9+
/** Stable subBlock id for an enrichment input (unique across enrichments). */
10+
const inputFieldId = (enrichmentId: string, inputId: string) => `${enrichmentId}__${inputId}`
11+
12+
// One input field per (enrichment, input), shown only for its enrichment.
13+
const inputSubBlocks = ALL_ENRICHMENTS.flatMap((enrichment) =>
14+
enrichment.inputs.map((input) => ({
15+
id: inputFieldId(enrichment.id, input.id),
16+
title: input.name,
17+
type: 'short-input' as const,
18+
placeholder: input.description ?? `Enter ${input.name.toLowerCase()}`,
19+
condition: { field: 'operation', value: enrichment.id },
20+
required: input.required ? ({ field: 'operation', value: enrichment.id } as const) : undefined,
21+
}))
22+
)
23+
24+
// Block input schema: the operation plus every per-enrichment input field.
25+
const blockInputs: Record<string, { type: ParamType; description: string }> = {
26+
operation: { type: 'string', description: 'Enrichment to run' },
27+
}
28+
for (const enrichment of ALL_ENRICHMENTS) {
29+
for (const input of enrichment.inputs) {
30+
blockInputs[inputFieldId(enrichment.id, input.id)] = {
31+
type: mapFieldType(input.type),
32+
description: `${input.name} (for ${enrichment.name})`,
33+
}
34+
}
35+
}
36+
37+
// Union of all enrichment outputs, each shown only for the enrichment(s) that
38+
// produce it.
39+
const outputProducers = new Map<string, { field: EnrichmentOutputField; operations: string[] }>()
40+
for (const enrichment of ALL_ENRICHMENTS) {
41+
for (const output of enrichment.outputs) {
42+
const entry = outputProducers.get(output.id) ?? { field: output, operations: [] }
43+
entry.operations.push(enrichment.id)
44+
outputProducers.set(output.id, entry)
45+
}
46+
}
47+
// Seed the enrichment outputs first so the reserved `matched` / `provider`
48+
// keys (assigned below) always win if a future enrichment ever declares an
49+
// output id that collides with them.
50+
const blockOutputs: Record<string, OutputFieldDefinition> = {}
51+
for (const [id, { field, operations }] of outputProducers) {
52+
blockOutputs[id] = {
53+
type: mapFieldType(field.type),
54+
description: field.name,
55+
condition: { field: 'operation', value: operations },
56+
}
57+
}
58+
blockOutputs.matched = {
59+
type: 'boolean',
60+
description: 'Whether the enrichment found a result',
61+
}
62+
blockOutputs.provider = {
63+
type: 'string',
64+
description: 'Provider whose result was returned (e.g. "Hunter", "People Data Labs")',
65+
}
66+
67+
/**
68+
* Enrichment block — runs a code-defined Sim enrichment (Work Email, Phone
69+
* Number, Company Domain, Company Info, …) and returns its outputs. Generated
70+
* from the enrichment registry, so new enrichments appear automatically. Runs
71+
* on the workspace's hosted / BYOK key (injected server-side); no credential.
72+
*/
73+
export const EnrichmentBlock: BlockConfig<EnrichmentRunResponse> = {
74+
type: 'enrichment',
75+
name: 'Data Enrichment',
76+
description: 'Enrich data with a Sim enrichment',
77+
longDescription:
78+
'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.',
79+
docsLink: 'https://docs.sim.ai/tools/enrichment',
80+
category: 'tools',
81+
integrationType: IntegrationType.Sales,
82+
tags: ['enrichment'],
83+
bgColor: '#9333EA',
84+
icon: EnrichmentIcon,
85+
86+
subBlocks: [
87+
{
88+
id: 'operation',
89+
title: 'Enrichment',
90+
type: 'dropdown',
91+
options: ALL_ENRICHMENTS.map((e) => ({ label: e.name, id: e.id })),
92+
value: () => ALL_ENRICHMENTS[0]?.id ?? '',
93+
},
94+
...inputSubBlocks,
95+
],
96+
97+
tools: {
98+
access: ['enrichment_run'],
99+
config: {
100+
tool: () => 'enrichment_run',
101+
params: (params) => {
102+
const enrichment = getEnrichment(params.operation)
103+
const inputs: Record<string, unknown> = {}
104+
if (enrichment) {
105+
for (const input of enrichment.inputs) {
106+
const value = params[inputFieldId(enrichment.id, input.id)]
107+
if (value !== undefined && value !== '') inputs[input.id] = value
108+
}
109+
}
110+
return { enrichmentId: params.operation, inputs }
111+
},
112+
},
113+
},
114+
115+
inputs: blockInputs,
116+
outputs: blockOutputs,
117+
}

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'
@@ -305,6 +306,7 @@ export const registry: Record<string, BlockConfig> = {
305306
elevenlabs: ElevenLabsBlock,
306307
fathom: FathomBlock,
307308
enrich: EnrichBlock,
309+
enrichment: EnrichmentBlock,
308310
evaluator: EvaluatorBlock,
309311
evernote: EvernoteBlock,
310312
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/providers.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
1-
import type { EnrichmentProvider } from '@/enrichments/types'
1+
import type {
2+
EnrichmentInputField,
3+
EnrichmentOutputField,
4+
EnrichmentProvider,
5+
} from '@/enrichments/types'
6+
7+
/**
8+
* Narrow union of the field types enrichments declare. Assignable to both the
9+
* tool `OutputType` and the block `ParamType` unions, so a single mapping
10+
* function feeds both sides without per-call casts.
11+
*/
12+
export type EnrichmentFieldType = 'string' | 'number' | 'boolean' | 'json'
13+
14+
/** Maps an enrichment input/output column type to a block/tool field type. */
15+
export function mapFieldType(
16+
type: EnrichmentInputField['type'] | EnrichmentOutputField['type']
17+
): EnrichmentFieldType {
18+
if (type === 'number') return 'number'
19+
if (type === 'boolean') return 'boolean'
20+
if (type === 'json') return 'json'
21+
return 'string'
22+
}
223

324
/** Coerces an unknown input value to a trimmed string (`''` when nullish). */
425
export function str(value: unknown): string {

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>

0 commit comments

Comments
 (0)