diff --git a/apps/docs/content/docs/en/tools/enrichment.mdx b/apps/docs/content/docs/en/tools/enrichment.mdx new file mode 100644 index 0000000000..06975f05a0 --- /dev/null +++ b/apps/docs/content/docs/en/tools/enrichment.mdx @@ -0,0 +1,48 @@ +--- +title: Enrichment +description: Enrich data with a Sim enrichment +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## 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) | + + diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 83bad8df3b..dbc2ef5b7f 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -49,6 +49,7 @@ "elevenlabs", "emailbison", "enrich", + "enrichment", "evernote", "exa", "extend", diff --git a/apps/sim/app/api/tools/enrichment/run/route.ts b/apps/sim/app/api/tools/enrichment/run/route.ts new file mode 100644 index 0000000000..586dc7f735 --- /dev/null +++ b/apps/sim/app/api/tools/enrichment/run/route.ts @@ -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, + }) +}) diff --git a/apps/sim/blocks/blocks/enrichment.ts b/apps/sim/blocks/blocks/enrichment.ts new file mode 100644 index 0000000000..12c700bc6b --- /dev/null +++ b/apps/sim/blocks/blocks/enrichment.ts @@ -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 = { + 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() +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 = {} +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 = { + 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 = {} + 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, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index ace529f98c..14f3bda53f 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -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 = { elevenlabs: ElevenLabsBlock, fathom: FathomBlock, enrich: EnrichBlock, + enrichment: EnrichmentBlock, evaluator: EvaluatorBlock, evernote: EvernoteBlock, exa: ExaBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 73ad59450b..7985328c08 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -1,6 +1,22 @@ import type { SVGProps } from 'react' import { useId } from 'react' +export function EnrichmentIcon(props: SVGProps) { + return ( + + + + + + ) +} + export function AgentMailIcon(props: SVGProps) { return ( diff --git a/apps/sim/enrichments/providers.ts b/apps/sim/enrichments/providers.ts index 3a2202933c..22982bc35a 100644 --- a/apps/sim/enrichments/providers.ts +++ b/apps/sim/enrichments/providers.ts @@ -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 { diff --git a/apps/sim/enrichments/run.ts b/apps/sim/enrichments/run.ts index 6fe7420ffa..5b9a16fadb 100644 --- a/apps/sim/enrichments/run.ts +++ b/apps/sim/enrichments/run.ts @@ -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 } } diff --git a/apps/sim/enrichments/types.ts b/apps/sim/enrichments/types.ts index 60c0180d61..7080e3b8c8 100644 --- a/apps/sim/enrichments/types.ts +++ b/apps/sim/enrichments/types.ts @@ -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 } diff --git a/apps/sim/lib/api/contracts/tools/enrichment.ts b/apps/sim/lib/api/contracts/tools/enrichment.ts new file mode 100644 index 0000000000..c3279dba68 --- /dev/null +++ b/apps/sim/lib/api/contracts/tools/enrichment.ts @@ -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 +export type RunEnrichmentResponse = z.output diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 0d99ea83ad..38b6f64db0 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -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(), }) diff --git a/apps/sim/tools/enrichment/index.ts b/apps/sim/tools/enrichment/index.ts new file mode 100644 index 0000000000..2d7ad39f66 --- /dev/null +++ b/apps/sim/tools/enrichment/index.ts @@ -0,0 +1 @@ +export { enrichmentRunTool } from './run' diff --git a/apps/sim/tools/enrichment/run.ts b/apps/sim/tools/enrichment/run.ts new file mode 100644 index 0000000000..de86e25459 --- /dev/null +++ b/apps/sim/tools/enrichment/run.ts @@ -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 = {} +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 = { + 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 + 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, + }, + }, +} diff --git a/apps/sim/tools/enrichment/types.ts b/apps/sim/tools/enrichment/types.ts new file mode 100644 index 0000000000..51e9d52656 --- /dev/null +++ b/apps/sim/tools/enrichment/types.ts @@ -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 +} + +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 +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 0f873aae5b..3070576fa4 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -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 = { 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, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 93b187d8aa..5b321a818d 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: 757, - zodRoutes: 757, + totalRoutes: 758, + zodRoutes: 758, nonZodRoutes: 0, } as const