Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/docs/content/docs/en/tools/enrichment.mdx
Original file line number Diff line number Diff line change
@@ -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) |


1 change: 1 addition & 0 deletions apps/docs/content/docs/en/tools/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"elevenlabs",
"emailbison",
"enrich",
"enrichment",
"evernote",
"exa",
"extend",
Expand Down
66 changes: 66 additions & 0 deletions apps/sim/app/api/tools/enrichment/run/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +54 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The matched flag is computed from Object.keys(result).length > 0, but the cascade's hasResult guard (in run.ts) requires at least one non-null, non-empty value before accepting a provider result. The two checks are currently equivalent in practice (the cascade guarantees result is either {} or has at least one non-empty value), but aligning the route to the same guard makes the intent explicit and prevents drift if the cascade logic changes.

Suggested change
logger.info('Enrichment block run', {
enrichmentId,
matched: Object.keys(result).length > 0,
provider,
})
return NextResponse.json({
matched: Object.keys(result).length > 0,
const matched = Object.values(result).some((v) => v !== undefined && v !== null && v !== '')
logger.info('Enrichment block run', {
enrichmentId,
matched,
provider,
})
return NextResponse.json({
matched,

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

result,
cost,
error,
provider,
})
})
117 changes: 117 additions & 0 deletions apps/sim/blocks/blocks/enrichment.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions apps/sim/blocks/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -305,6 +306,7 @@ export const registry: Record<string, BlockConfig> = {
elevenlabs: ElevenLabsBlock,
fathom: FathomBlock,
enrich: EnrichBlock,
enrichment: EnrichmentBlock,
evaluator: EvaluatorBlock,
evernote: EvernoteBlock,
exa: ExaBlock,
Expand Down
16 changes: 16 additions & 0 deletions apps/sim/components/icons.tsx
Original file line number Diff line number Diff line change
@@ -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'>
Expand Down
23 changes: 22 additions & 1 deletion apps/sim/enrichments/providers.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/enrichments/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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++
Expand All @@ -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 }
}
10 changes: 7 additions & 3 deletions apps/sim/enrichments/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
29 changes: 29 additions & 0 deletions apps/sim/lib/api/contracts/tools/enrichment.ts
Original file line number Diff line number Diff line change
@@ -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>
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export const workflowStateSchema = z.object({
metadata: z
.object({
name: z.string().optional(),
description: z.string().optional(),
description: z.string().nullable().optional(),
})
.optional(),
})
Expand Down
1 change: 1 addition & 0 deletions apps/sim/tools/enrichment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { enrichmentRunTool } from './run'
Loading
Loading