feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza (#4777)

* feat(integrations): hosted API keys for Findymail, Prospeo, and Wiza

Add hosted-key support across all credit-consuming Findymail, Prospeo, and Wiza operations so Sim provides the key when a workspace has not brought its own. Register the three BYOK providers, consolidate Wiza's two-step reveal into a single polling wiza_individual_reveal op, and hide the API key field on hosted Sim for hosted operations.

* fix(integrations): harden Wiza reveal polling, soften enrichment getCost guards

Address Greptile + Cursor Bugbot review on #4777: return explicit failures from the Wiza individual_reveal poller instead of throwing (thrown errors were swallowed into a false queued success), short-circuit when the initial reveal is already terminal, tolerate transient 5xx/429 during polling, and return 0 (not throw) from Findymail getCost when the contacts/employees array is absent.

* chore(integrations): biome formatting after wiza merge resolution

* fix(wiza): type isTerminalReveal param structurally for next build typecheck

* feat(enrichments): add Findymail, Prospeo, Wiza to work-email waterfall

* feat(enrichments): add Wiza + Prospeo phone reveal to phone-number waterfall

* feat(enrichments): opportunistic identifiers + LinkedIn URL input across work-email & phone cascades
This commit is contained in:
Theodore Li
2026-05-29 18:36:59 -04:00
committed by GitHub
parent 4b0dab4682
commit 704362dc5e
38 changed files with 1322 additions and 439 deletions
@@ -19,6 +19,7 @@ import {
AnthropicIcon,
BrandfetchIcon,
ExaAIIcon,
FindymailIcon,
FirecrawlIcon,
FireworksIcon,
GeminiIcon,
@@ -32,7 +33,9 @@ import {
ParallelIcon,
PeopleDataLabsIcon,
PerplexityIcon,
ProspeoIcon,
SerperIcon,
WizaIcon,
} from '@/components/icons'
import { Input } from '@/components/ui'
import { BYOKKeySkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
@@ -172,6 +175,27 @@ const PROVIDERS: {
description: 'Person and company enrichment, search, and identity',
placeholder: 'Enter your People Data Labs API key',
},
{
id: 'findymail',
name: 'Findymail',
icon: FindymailIcon,
description: 'Email finder, verification, and phone lookup',
placeholder: 'Enter your Findymail API key',
},
{
id: 'prospeo',
name: 'Prospeo',
icon: ProspeoIcon,
description: 'Person and company enrichment and search',
placeholder: 'Enter your Prospeo API key',
},
{
id: 'wiza',
name: 'Wiza',
icon: WizaIcon,
description: 'Prospect search, individual reveal, and company enrichment',
placeholder: 'Enter your Wiza API key',
},
]
export function BYOK() {
+13 -1
View File
@@ -214,7 +214,7 @@ export const FindymailBlock: BlockConfig<FindymailResponse> = {
placeholder: 'e.g. React, TypeScript, Node.js',
},
},
// API Key
// API Key — hidden on hosted Sim for operations with hosted-key support
{
id: 'apiKey',
title: 'API Key',
@@ -222,6 +222,18 @@ export const FindymailBlock: BlockConfig<FindymailResponse> = {
required: true,
placeholder: 'Enter your Findymail API key',
password: true,
hideWhenHosted: true,
condition: { field: 'operation', value: 'findymail_get_credits', not: true },
},
// API Key — always required for the credit-balance lookup (no hosted key)
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
required: true,
placeholder: 'Enter your Findymail API key',
password: true,
condition: { field: 'operation', value: 'findymail_get_credits' },
},
],
tools: {
+20 -1
View File
@@ -302,7 +302,7 @@ export const ProspeoBlock: BlockConfig<ProspeoResponse> = {
condition: { field: 'operation', value: 'prospeo_search_suggestions' },
},
// API Key (always last)
// API Key — hidden on hosted Sim for operations with hosted-key support
{
id: 'apiKey',
title: 'API Key',
@@ -310,6 +310,25 @@ export const ProspeoBlock: BlockConfig<ProspeoResponse> = {
required: true,
placeholder: 'Enter your Prospeo API key',
password: true,
hideWhenHosted: true,
condition: {
field: 'operation',
value: ['prospeo_search_suggestions', 'prospeo_account_information'],
not: true,
},
},
// API Key — always required for the free account/suggestion lookups (no hosted key)
{
id: 'apiKey',
title: 'API Key',
type: 'short-input',
required: true,
placeholder: 'Enter your Prospeo API key',
password: true,
condition: {
field: 'operation',
value: ['prospeo_search_suggestions', 'prospeo_account_information'],
},
},
],
tools: {
+61 -76
View File
@@ -24,8 +24,7 @@ export const WizaBlock: BlockConfig<WizaResponse> = {
options: [
{ label: 'Prospect Search', id: 'prospect_search' },
{ label: 'Company Enrichment', id: 'company_enrichment' },
{ label: 'Start Individual Reveal', id: 'start_individual_reveal' },
{ label: 'Get Individual Reveal', id: 'get_individual_reveal' },
{ label: 'Individual Reveal', id: 'individual_reveal' },
{ label: 'Get Credits', id: 'get_credits' },
],
value: () => 'prospect_search',
@@ -37,6 +36,17 @@ export const WizaBlock: BlockConfig<WizaResponse> = {
placeholder: 'Enter your Wiza API key',
password: true,
required: true,
hideWhenHosted: true,
condition: { field: 'operation', value: 'get_credits', not: true },
},
{
id: 'apiKey',
title: 'Wiza API Key',
type: 'short-input',
placeholder: 'Enter your Wiza API key',
password: true,
required: true,
condition: { field: 'operation', value: 'get_credits' },
},
// Prospect Search
@@ -287,7 +297,7 @@ Return ONLY the JSON object - no explanations, no extra text.`,
mode: 'advanced',
},
// Start Individual Reveal
// Individual Reveal
{
id: 'enrichment_level',
title: 'Enrichment Level',
@@ -298,85 +308,66 @@ Return ONLY the JSON object - no explanations, no extra text.`,
{ label: 'Phone', id: 'phone' },
{ label: 'Full', id: 'full' },
],
value: () => 'partial',
condition: { field: 'operation', value: 'start_individual_reveal' },
required: { field: 'operation', value: 'start_individual_reveal' },
value: () => 'full',
condition: { field: 'operation', value: 'individual_reveal' },
required: { field: 'operation', value: 'individual_reveal' },
},
{
id: 'profile_url',
title: 'LinkedIn Profile URL',
type: 'short-input',
placeholder: 'https://linkedin.com/in/johndoe',
condition: { field: 'operation', value: 'start_individual_reveal' },
condition: { field: 'operation', value: 'individual_reveal' },
},
{
id: 'full_name',
title: 'Full Name',
type: 'short-input',
placeholder: 'John Doe',
condition: { field: 'operation', value: 'start_individual_reveal' },
condition: { field: 'operation', value: 'individual_reveal' },
},
{
id: 'company',
title: 'Company',
type: 'short-input',
placeholder: 'Wiza',
condition: { field: 'operation', value: 'start_individual_reveal' },
condition: { field: 'operation', value: 'individual_reveal' },
},
{
id: 'domain',
title: 'Company Domain',
type: 'short-input',
placeholder: 'wiza.co',
condition: { field: 'operation', value: 'start_individual_reveal' },
condition: { field: 'operation', value: 'individual_reveal' },
},
{
id: 'email',
title: 'Email',
type: 'short-input',
placeholder: 'john@wiza.co',
condition: { field: 'operation', value: 'start_individual_reveal' },
condition: { field: 'operation', value: 'individual_reveal' },
},
{
id: 'accept_work',
title: 'Accept Work Emails',
type: 'switch',
condition: { field: 'operation', value: 'start_individual_reveal' },
condition: { field: 'operation', value: 'individual_reveal' },
mode: 'advanced',
},
{
id: 'accept_personal',
title: 'Accept Personal Emails',
type: 'switch',
condition: { field: 'operation', value: 'start_individual_reveal' },
condition: { field: 'operation', value: 'individual_reveal' },
mode: 'advanced',
},
{
id: 'callback_url',
title: 'Callback URL',
type: 'short-input',
placeholder: 'https://example.com/wiza-callback',
condition: { field: 'operation', value: 'start_individual_reveal' },
mode: 'advanced',
},
// Get Individual Reveal
{
id: 'id',
title: 'Reveal ID',
type: 'short-input',
placeholder: 'Reveal ID returned from Start Individual Reveal',
condition: { field: 'operation', value: 'get_individual_reveal' },
required: { field: 'operation', value: 'get_individual_reveal' },
},
],
tools: {
access: [
'wiza_prospect_search',
'wiza_company_enrichment',
'wiza_start_individual_reveal',
'wiza_get_individual_reveal',
'wiza_individual_reveal',
'wiza_get_credits',
],
config: {
@@ -386,10 +377,8 @@ Return ONLY the JSON object - no explanations, no extra text.`,
return 'wiza_prospect_search'
case 'company_enrichment':
return 'wiza_company_enrichment'
case 'start_individual_reveal':
return 'wiza_start_individual_reveal'
case 'get_individual_reveal':
return 'wiza_get_individual_reveal'
case 'individual_reveal':
return 'wiza_individual_reveal'
case 'get_credits':
return 'wiza_get_credits'
default:
@@ -479,8 +468,6 @@ Return ONLY the JSON object - no explanations, no extra text.`,
email: { type: 'string', description: 'Email address' },
accept_work: { type: 'boolean', description: 'Whether to accept work emails' },
accept_personal: { type: 'boolean', description: 'Whether to accept personal emails' },
callback_url: { type: 'string', description: 'Callback URL' },
id: { type: 'string', description: 'Individual reveal ID' },
},
outputs: {
@@ -495,46 +482,44 @@ Return ONLY the JSON object - no explanations, no extra text.`,
},
id: {
type: 'number',
description: 'Reveal ID (start_individual_reveal, get_individual_reveal)',
description: 'Reveal ID (individual_reveal)',
},
status: {
type: 'string',
description:
'Reveal status (start_individual_reveal, get_individual_reveal): queued | resolving | finished | failed',
description: 'Reveal status (individual_reveal): queued | resolving | finished | failed',
},
is_complete: {
type: 'boolean',
description:
'Whether the reveal has completed (start_individual_reveal, get_individual_reveal)',
description: 'Whether the reveal has completed (individual_reveal)',
},
name: { type: 'string', description: 'Full name (get_individual_reveal)' },
company: { type: 'string', description: 'Company name (get_individual_reveal)' },
name: { type: 'string', description: 'Full name (individual_reveal)' },
company: { type: 'string', description: 'Company name (individual_reveal)' },
enrichment_level: {
type: 'string',
description: 'Enrichment level used (get_individual_reveal)',
description: 'Enrichment level used (individual_reveal)',
},
linkedin_profile_url: { type: 'string', description: 'LinkedIn URL (get_individual_reveal)' },
title: { type: 'string', description: 'Job title (get_individual_reveal)' },
location: { type: 'string', description: 'Location (get_individual_reveal)' },
email: { type: 'string', description: 'Primary email (get_individual_reveal)' },
email_type: { type: 'string', description: 'Primary email type (get_individual_reveal)' },
linkedin_profile_url: { type: 'string', description: 'LinkedIn URL (individual_reveal)' },
title: { type: 'string', description: 'Job title (individual_reveal)' },
location: { type: 'string', description: 'Location (individual_reveal)' },
email: { type: 'string', description: 'Primary email (individual_reveal)' },
email_type: { type: 'string', description: 'Primary email type (individual_reveal)' },
email_status: {
type: 'string',
description: 'Primary email status: valid | risky | unfound (get_individual_reveal)',
description: 'Primary email status: valid | risky | unfound (individual_reveal)',
},
emails: {
type: 'json',
description: 'All emails found (get_individual_reveal): [{email, email_type, email_status}]',
description: 'All emails found (individual_reveal): [{email, email_type, email_status}]',
},
mobile_phone: { type: 'string', description: 'Mobile phone (get_individual_reveal)' },
phone_number: { type: 'string', description: 'Direct/office phone (get_individual_reveal)' },
mobile_phone: { type: 'string', description: 'Mobile phone (individual_reveal)' },
phone_number: { type: 'string', description: 'Direct/office phone (individual_reveal)' },
phone_status: {
type: 'string',
description: 'Phone status: found | unfound (get_individual_reveal)',
description: 'Phone status: found | unfound (individual_reveal)',
},
phones: {
type: 'json',
description: 'All phones found (get_individual_reveal): [{number, pretty_number, type}]',
description: 'All phones found (individual_reveal): [{number, pretty_number, type}]',
},
company_name: {
type: 'string',
@@ -542,41 +527,41 @@ Return ONLY the JSON object - no explanations, no extra text.`,
},
company_domain: {
type: 'string',
description: 'Company domain (company_enrichment, get_individual_reveal)',
description: 'Company domain (company_enrichment, individual_reveal)',
},
domain: { type: 'string', description: 'Domain (company_enrichment)' },
company_industry: {
type: 'string',
description: 'Industry (company_enrichment, get_individual_reveal)',
description: 'Industry (company_enrichment, individual_reveal)',
},
company_size: {
type: 'number',
description: 'Employee count (company_enrichment, get_individual_reveal)',
description: 'Employee count (company_enrichment, individual_reveal)',
},
company_size_range: {
type: 'string',
description: 'Headcount range (company_enrichment, get_individual_reveal)',
description: 'Headcount range (company_enrichment, individual_reveal)',
},
company_founded: {
type: 'number',
description: 'Year founded (company_enrichment, get_individual_reveal)',
description: 'Year founded (company_enrichment, individual_reveal)',
},
company_revenue_range: {
type: 'string',
description: 'Revenue range (company_enrichment)',
},
company_revenue: { type: 'string', description: 'Revenue (get_individual_reveal)' },
company_revenue: { type: 'string', description: 'Revenue (individual_reveal)' },
company_funding: {
type: 'string',
description: 'Total funding (company_enrichment, get_individual_reveal)',
description: 'Total funding (company_enrichment, individual_reveal)',
},
company_type: {
type: 'string',
description: 'Company type (company_enrichment, get_individual_reveal)',
description: 'Company type (company_enrichment, individual_reveal)',
},
company_description: {
type: 'string',
description: 'Company description (company_enrichment, get_individual_reveal)',
description: 'Company description (company_enrichment, individual_reveal)',
},
company_ticker: { type: 'string', description: 'Stock ticker (company_enrichment)' },
company_last_funding_round: {
@@ -593,40 +578,40 @@ Return ONLY the JSON object - no explanations, no extra text.`,
},
company_location: {
type: 'string',
description: 'Full location string (company_enrichment, get_individual_reveal)',
description: 'Full location string (company_enrichment, individual_reveal)',
},
company_twitter: { type: 'string', description: 'Twitter URL (company_enrichment)' },
company_facebook: { type: 'string', description: 'Facebook URL (company_enrichment)' },
company_linkedin: {
type: 'string',
description: 'LinkedIn URL (company_enrichment, get_individual_reveal)',
description: 'LinkedIn URL (company_enrichment, individual_reveal)',
},
company_linkedin_id: { type: 'string', description: 'LinkedIn ID (company_enrichment)' },
company_street: {
type: 'string',
description: 'Street address (company_enrichment, get_individual_reveal)',
description: 'Street address (company_enrichment, individual_reveal)',
},
company_locality: {
type: 'string',
description: 'City (company_enrichment, get_individual_reveal)',
description: 'City (company_enrichment, individual_reveal)',
},
company_region: {
type: 'string',
description: 'State/region (company_enrichment, get_individual_reveal)',
description: 'State/region (company_enrichment, individual_reveal)',
},
company_postal_code: {
type: 'string',
description: 'Postal code (company_enrichment, get_individual_reveal)',
description: 'Postal code (company_enrichment, individual_reveal)',
},
company_country: {
type: 'string',
description: 'Country (company_enrichment, get_individual_reveal)',
description: 'Country (company_enrichment, individual_reveal)',
},
company_subindustry: { type: 'string', description: 'Subindustry (get_individual_reveal)' },
company_subindustry: { type: 'string', description: 'Subindustry (individual_reveal)' },
credits: {
type: 'json',
description:
'Credits deducted — company_enrichment: { api_credits: { total, company_credits } }; get_individual_reveal: { api_credits: { total, email_credits, phone_credits, scrape_credits } }',
'Credits deducted — company_enrichment: { api_credits: { total, company_credits } }; individual_reveal: { api_credits: { total, email_credits, phone_credits, scrape_credits } }',
},
email_credits: {
type: 'json',
@@ -0,0 +1,78 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { phoneNumberEnrichment } from '@/enrichments/phone-number/phone-number'
import type { EnrichmentProvider } from '@/enrichments/types'
function provider(id: string): EnrichmentProvider {
const p = phoneNumberEnrichment.providers.find((x) => x.id === id)
if (!p) throw new Error(`Provider ${id} not found in phone-number cascade`)
return p
}
const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' }
describe('phone-number enrichment cascade', () => {
it('chains PDL then the phone-capable hosted providers', () => {
expect(phoneNumberEnrichment.providers.map((p) => p.id)).toEqual([
'pdl',
'wiza',
'findymail',
'prospeo',
])
})
describe('wiza (opportunistic)', () => {
const p = provider('wiza')
it('reveals phone, using name+domain or LinkedIn profile_url', () => {
expect(p.toolId).toBe('wiza_individual_reveal')
expect(p.buildParams(nameDomain)).toEqual({
full_name: 'John Doe',
domain: 'acme.com',
enrichment_level: 'phone',
})
expect(p.buildParams(linkedinOnly)).toEqual({
full_name: 'John Doe',
profile_url: 'https://linkedin.com/in/johndoe',
enrichment_level: 'phone',
})
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
expect(p.mapOutput({ mobile_phone: '+1555', phones: [] })).toEqual({ phone: '+1555' })
expect(p.mapOutput({ phones: [{ number: '+1777' }] })).toEqual({ phone: '+1777' })
})
})
describe('findymail', () => {
const p = provider('findymail')
it('keys off the LinkedIn URL and skips without one', () => {
expect(p.toolId).toBe('findymail_find_phone')
expect(p.buildParams(linkedinOnly)).toEqual({
linkedin_url: 'https://linkedin.com/in/johndoe',
})
expect(p.buildParams(nameDomain)).toBeNull()
expect(p.mapOutput({ phone: '+1555' })).toEqual({ phone: '+1555' })
expect(p.mapOutput({ phone: null })).toBeNull()
})
})
describe('prospeo (opportunistic)', () => {
const p = provider('prospeo')
it('requests mobile enrichment via name+domain or LinkedIn', () => {
expect(p.toolId).toBe('prospeo_enrich_person')
expect(p.buildParams(nameDomain)).toEqual({
full_name: 'John Doe',
company_website: 'acme.com',
enrich_mobile: true,
})
expect(p.buildParams(linkedinOnly)).toEqual({
full_name: 'John Doe',
linkedin_url: 'https://linkedin.com/in/johndoe',
enrich_mobile: true,
})
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
expect(p.mapOutput({ person: { mobile: { mobile: '+1555' } } })).toEqual({ phone: '+1555' })
})
})
})
@@ -4,17 +4,22 @@ import { firstNonEmpty, normalizeDomain, str, toolProvider } from '@/enrichments
import type { EnrichmentConfig } from '@/enrichments/types'
/**
* Phone Number enrichment. Finds a contact's phone number from their full name
* and (optionally) company domain via a People Data Labs person match.
* Phone Number enrichment. Finds a contact's phone number from a full name plus
* any available identifiers (company domain, LinkedIn URL) via a waterfall:
* People Data Labs (name match) → Wiza reveal → Findymail (LinkedIn) → Prospeo
* mobile. Each provider opportunistically uses whatever identifiers the row
* provides and self-skips when it has none usable, so adding more inputs widens
* coverage without reordering. First phone wins; all providers support hosted keys.
*/
export const phoneNumberEnrichment: EnrichmentConfig = {
id: 'phone-number',
name: 'Phone Number',
description: "Find a contact's phone number from their name and company domain.",
description: "Find a contact's phone number from their name, company, or LinkedIn URL.",
icon: Phone,
inputs: [
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
{ id: 'companyDomain', name: 'Company domain', type: 'string' },
{ id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' },
],
outputs: [{ id: 'phone', name: 'phone', type: 'string' }],
providers: [
@@ -37,5 +42,69 @@ export const phoneNumberEnrichment: EnrichmentConfig = {
return phone ? { phone } : null
},
}),
toolProvider({
id: 'wiza',
label: 'Wiza',
toolId: 'wiza_individual_reveal',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
const fullName = str(inputs.fullName)
const domain = normalizeDomain(inputs.companyDomain)
// Needs a LinkedIn URL or a name+domain pair; skip otherwise.
if (!linkedin && !(fullName && domain)) return null
// 'phone' reveals the mobile number (5 credits). Prefer LinkedIn when present.
return filterUndefined({
profile_url: linkedin || undefined,
full_name: fullName || undefined,
domain: domain || undefined,
enrichment_level: 'phone',
})
},
mapOutput: (output) => {
const phones = Array.isArray(output.phones)
? (output.phones as Record<string, unknown>[])
: []
const phone = str(output.mobile_phone) || str(output.phone_number) || str(phones[0]?.number)
return phone ? { phone } : null
},
}),
toolProvider({
id: 'findymail',
label: 'Findymail',
toolId: 'findymail_find_phone',
buildParams: (inputs) => {
// Findymail's phone finder keys off a LinkedIn URL only.
const linkedin = str(inputs.linkedinUrl)
if (!linkedin) return null
return { linkedin_url: linkedin }
},
mapOutput: (output) => {
const phone = str(output.phone)
return phone ? { phone } : null
},
}),
toolProvider({
id: 'prospeo',
label: 'Prospeo',
toolId: 'prospeo_enrich_person',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
const fullName = str(inputs.fullName)
const companyWebsite = normalizeDomain(inputs.companyDomain)
if (!linkedin && !(fullName && companyWebsite)) return null
return filterUndefined({
linkedin_url: linkedin || undefined,
full_name: fullName || undefined,
company_website: companyWebsite || undefined,
enrich_mobile: true,
})
},
mapOutput: (output) => {
const person = output.person as Record<string, unknown> | undefined
const mobile = person?.mobile as Record<string, unknown> | undefined
const phone = str(mobile?.mobile)
return phone ? { phone } : null
},
}),
],
}
@@ -0,0 +1,86 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { EnrichmentProvider } from '@/enrichments/types'
import { workEmailEnrichment } from '@/enrichments/work-email/work-email'
function provider(id: string): EnrichmentProvider {
const p = workEmailEnrichment.providers.find((x) => x.id === id)
if (!p) throw new Error(`Provider ${id} not found in work-email cascade`)
return p
}
const nameDomain = { fullName: 'John Doe', companyDomain: 'https://www.acme.com/careers' }
const linkedinOnly = { fullName: 'John Doe', linkedinUrl: 'https://linkedin.com/in/johndoe' }
describe('work-email enrichment cascade', () => {
it('chains the hosted providers in waterfall order', () => {
expect(workEmailEnrichment.providers.map((p) => p.id)).toEqual([
'hunter',
'findymail',
'findymail-linkedin',
'prospeo',
'wiza',
'pdl',
])
})
describe('findymail (name)', () => {
const p = provider('findymail')
it('maps name + domain and extracts contact.email', () => {
expect(p.toolId).toBe('findymail_find_email_from_name')
expect(p.buildParams(nameDomain)).toEqual({ name: 'John Doe', domain: 'acme.com' })
expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' })
expect(p.buildParams(linkedinOnly)).toBeNull()
})
})
describe('findymail-linkedin', () => {
const p = provider('findymail-linkedin')
it('keys off the LinkedIn URL and skips without one', () => {
expect(p.toolId).toBe('findymail_find_email_from_linkedin')
expect(p.buildParams(linkedinOnly)).toEqual({
linkedin_url: 'https://linkedin.com/in/johndoe',
})
expect(p.buildParams(nameDomain)).toBeNull()
expect(p.mapOutput({ contact: { email: 'j@acme.com' } })).toEqual({ email: 'j@acme.com' })
})
})
describe('prospeo (opportunistic)', () => {
const p = provider('prospeo')
it('uses name+domain, or LinkedIn when present', () => {
expect(p.buildParams(nameDomain)).toEqual({
full_name: 'John Doe',
company_website: 'acme.com',
})
expect(p.buildParams(linkedinOnly)).toEqual({
full_name: 'John Doe',
linkedin_url: 'https://linkedin.com/in/johndoe',
})
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
expect(p.mapOutput({ person: { email: { email: 'j@acme.com' } } })).toEqual({
email: 'j@acme.com',
})
})
})
describe('wiza (opportunistic)', () => {
const p = provider('wiza')
it('reveals email-only (partial), preferring LinkedIn profile_url', () => {
expect(p.buildParams(nameDomain)).toEqual({
full_name: 'John Doe',
domain: 'acme.com',
enrichment_level: 'partial',
})
expect(p.buildParams(linkedinOnly)).toEqual({
full_name: 'John Doe',
profile_url: 'https://linkedin.com/in/johndoe',
enrichment_level: 'partial',
})
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
expect(p.mapOutput({ email: 'j@acme.com' })).toEqual({ email: 'j@acme.com' })
})
})
})
+85 -5
View File
@@ -4,18 +4,23 @@ import { normalizeDomain, splitName, str, toolProvider } from '@/enrichments/pro
import type { EnrichmentConfig } from '@/enrichments/types'
/**
* Work Email enrichment. Finds a person's work email from their full name and
* company domain, trying Hunter first (deterministic finder) then People Data
* Labs (record match) as a fallback.
* Work Email enrichment. Finds a person's work email from a full name plus any
* available identifiers (company domain, LinkedIn URL) via a provider waterfall:
* deterministic finders first (Hunter, Findymail by name then by LinkedIn), then
* enrichment/reveal providers (Prospeo, Wiza), then People Data Labs as a broad
* record-match fallback. Each provider opportunistically uses whatever
* identifiers the row provides and self-skips when it has none usable, so adding
* more inputs widens coverage. First email wins; all providers support hosted keys.
*/
export const workEmailEnrichment: EnrichmentConfig = {
id: 'work-email',
name: 'Work Email',
description: "Find a person's work email from their name and company domain.",
description: "Find a person's work email from their name, company, or LinkedIn URL.",
icon: Mail,
inputs: [
{ id: 'fullName', name: 'Full name', type: 'string', required: true },
{ id: 'companyDomain', name: 'Company domain', type: 'string', required: true },
{ id: 'companyDomain', name: 'Company domain', type: 'string' },
{ id: 'linkedinUrl', name: 'LinkedIn URL', type: 'string' },
],
outputs: [{ id: 'email', name: 'email', type: 'string' }],
providers: [
@@ -34,6 +39,81 @@ export const workEmailEnrichment: EnrichmentConfig = {
return email ? { email } : null
},
}),
toolProvider({
id: 'findymail',
label: 'Findymail',
toolId: 'findymail_find_email_from_name',
buildParams: (inputs) => {
const name = str(inputs.fullName)
const domain = normalizeDomain(inputs.companyDomain)
if (!name || !domain) return null
return { name, domain }
},
mapOutput: (output) => {
const contact = output.contact as Record<string, unknown> | null
const email = str(contact?.email)
return email ? { email } : null
},
}),
toolProvider({
id: 'findymail-linkedin',
label: 'Findymail (LinkedIn)',
toolId: 'findymail_find_email_from_linkedin',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
if (!linkedin) return null
return { linkedin_url: linkedin }
},
mapOutput: (output) => {
const contact = output.contact as Record<string, unknown> | null
const email = str(contact?.email)
return email ? { email } : null
},
}),
toolProvider({
id: 'prospeo',
label: 'Prospeo',
toolId: 'prospeo_enrich_person',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
const fullName = str(inputs.fullName)
const companyWebsite = normalizeDomain(inputs.companyDomain)
if (!linkedin && !(fullName && companyWebsite)) return null
return filterUndefined({
linkedin_url: linkedin || undefined,
full_name: fullName || undefined,
company_website: companyWebsite || undefined,
})
},
mapOutput: (output) => {
const person = output.person as Record<string, unknown> | undefined
const emailObj = person?.email as Record<string, unknown> | undefined
const email = str(emailObj?.email)
return email ? { email } : null
},
}),
toolProvider({
id: 'wiza',
label: 'Wiza',
toolId: 'wiza_individual_reveal',
buildParams: (inputs) => {
const linkedin = str(inputs.linkedinUrl)
const fullName = str(inputs.fullName)
const domain = normalizeDomain(inputs.companyDomain)
if (!linkedin && !(fullName && domain)) return null
// 'partial' reveals the email only (2 credits); avoids phone charges.
return filterUndefined({
profile_url: linkedin || undefined,
full_name: fullName || undefined,
domain: domain || undefined,
enrichment_level: 'partial',
})
},
mapOutput: (output) => {
const email = str(output.email)
return email ? { email } : null
},
}),
toolProvider({
id: 'pdl',
label: 'People Data Labs',
+3
View File
@@ -20,6 +20,9 @@ export const byokProviderIdSchema = z.enum([
'cohere',
'hunter',
'peopledatalabs',
'findymail',
'prospeo',
'wiza',
])
export const byokKeySchema = z.object({
+277
View File
@@ -0,0 +1,277 @@
/**
* @vitest-environment node
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { findEmailFromNameTool } from '@/tools/findymail/find_email_from_name'
import { findEmailsByDomainTool } from '@/tools/findymail/find_emails_by_domain'
import { findPhoneTool } from '@/tools/findymail/find_phone'
import { FINDYMAIL_CREDIT_USD } from '@/tools/findymail/hosting'
import { reverseEmailLookupTool } from '@/tools/findymail/reverse_email_lookup'
import { verifyEmailTool } from '@/tools/findymail/verify_email'
import { bulkEnrichPersonTool } from '@/tools/prospeo/bulk_enrich_person'
import { enrichCompanyTool } from '@/tools/prospeo/enrich_company'
import { enrichPersonTool } from '@/tools/prospeo/enrich_person'
import { PROSPEO_CREDIT_USD } from '@/tools/prospeo/hosting'
import { searchPersonTool } from '@/tools/prospeo/search_person'
import type { ToolConfig } from '@/tools/types'
import { wizaCompanyEnrichmentTool } from '@/tools/wiza/company_enrichment'
import { WIZA_CREDIT_USD } from '@/tools/wiza/hosting'
import { wizaIndividualRevealTool } from '@/tools/wiza/individual_reveal'
import { wizaProspectSearchTool } from '@/tools/wiza/prospect_search'
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
function cost(tool: ToolConfig<any, any>, params: any, output: Record<string, unknown>) {
const pricing = tool.hosting?.pricing
if (!pricing || pricing.type !== 'custom') throw new Error('Expected custom pricing')
const result = pricing.getCost(params, output)
return typeof result === 'number' ? { cost: result } : result
}
describe('Findymail hosted key pricing', () => {
it('declares hosting with the shared env prefix and BYOK provider', () => {
expect(findEmailFromNameTool.hosting?.envKeyPrefix).toBe('FINDYMAIL_API_KEY')
expect(findEmailFromNameTool.hosting?.byokProviderId).toBe('findymail')
})
it('charges one credit only when an email is found', () => {
expect(cost(findEmailFromNameTool, {}, { contact: { email: 'a@b.com' } }).cost).toBeCloseTo(
FINDYMAIL_CREDIT_USD
)
expect(cost(findEmailFromNameTool, {}, { contact: null }).cost).toBe(0)
})
it('charges 10 credits for a found phone', () => {
expect(cost(findPhoneTool, {}, { phone: '+1555' }).cost).toBeCloseTo(10 * FINDYMAIL_CREDIT_USD)
expect(cost(findPhoneTool, {}, { phone: null }).cost).toBe(0)
})
it('charges one credit per contact returned by domain search', () => {
expect(cost(findEmailsByDomainTool, {}, { contacts: [{}, {}, {}] }).cost).toBeCloseTo(
3 * FINDYMAIL_CREDIT_USD
)
})
it('charges 2 credits for a reverse lookup with profile enrichment, 1 without', () => {
expect(
cost(reverseEmailLookupTool, { with_profile: true }, { email: 'a@b.com' }).cost
).toBeCloseTo(2 * FINDYMAIL_CREDIT_USD)
expect(
cost(reverseEmailLookupTool, { with_profile: false }, { email: 'a@b.com' }).cost
).toBeCloseTo(FINDYMAIL_CREDIT_USD)
expect(
cost(reverseEmailLookupTool, {}, { email: null, linkedin_url: null, fullName: null }).cost
).toBe(0)
})
it('charges one verifier credit per verification', () => {
expect(cost(verifyEmailTool, {}, { verified: true }).cost).toBeCloseTo(FINDYMAIL_CREDIT_USD)
})
})
describe('Prospeo hosted key pricing', () => {
it('declares hosting with the shared env prefix and BYOK provider', () => {
expect(enrichPersonTool.hosting?.envKeyPrefix).toBe('PROSPEO_API_KEY')
expect(enrichPersonTool.hosting?.byokProviderId).toBe('prospeo')
})
it('charges 1 credit for a person match and 10 when a mobile is revealed', () => {
expect(cost(enrichPersonTool, {}, { free_enrichment: false, person: {} }).cost).toBeCloseTo(
PROSPEO_CREDIT_USD
)
expect(
cost(enrichPersonTool, {}, { free_enrichment: false, person: { mobile: { revealed: true } } })
.cost
).toBeCloseTo(10 * PROSPEO_CREDIT_USD)
})
it('does not charge on a free or no-match enrichment', () => {
expect(cost(enrichPersonTool, {}, { free_enrichment: true, person: {} }).cost).toBe(0)
expect(cost(enrichPersonTool, {}, { free_enrichment: false, person: null }).cost).toBe(0)
expect(cost(enrichCompanyTool, {}, { free_enrichment: false, company: null }).cost).toBe(0)
})
it('uses the API-reported total_cost for bulk endpoints', () => {
expect(cost(bulkEnrichPersonTool, {}, { total_cost: 7 }).cost).toBeCloseTo(
7 * PROSPEO_CREDIT_USD
)
})
it('throws when bulk total_cost is missing', () => {
expect(() => cost(bulkEnrichPersonTool, {}, { matched: [] })).toThrow(/total_cost/)
})
it('charges one credit per non-free search page with results', () => {
expect(cost(searchPersonTool, {}, { free: false, results: [{}] }).cost).toBeCloseTo(
PROSPEO_CREDIT_USD
)
expect(cost(searchPersonTool, {}, { free: true, results: [{}] }).cost).toBe(0)
expect(cost(searchPersonTool, {}, { free: false, results: [] }).cost).toBe(0)
})
})
describe('Wiza hosted key pricing', () => {
it('declares hosting with the shared env prefix and BYOK provider', () => {
expect(wizaIndividualRevealTool.hosting?.envKeyPrefix).toBe('WIZA_API_KEY')
expect(wizaIndividualRevealTool.hosting?.byokProviderId).toBe('wiza')
})
it('charges 2 credits for a valid email and 5 for a phone on individual reveal', () => {
expect(
cost(wizaIndividualRevealTool, {}, { email_status: 'valid', phones: [] }).cost
).toBeCloseTo(2 * WIZA_CREDIT_USD)
expect(
cost(wizaIndividualRevealTool, {}, { email_status: 'unfound', mobile_phone: '+1555' }).cost
).toBeCloseTo(5 * WIZA_CREDIT_USD)
expect(
cost(wizaIndividualRevealTool, {}, { email_status: 'valid', phones: [{ number: '+1555' }] })
.cost
).toBeCloseTo(7 * WIZA_CREDIT_USD)
expect(cost(wizaIndividualRevealTool, {}, { email_status: 'unfound', phones: [] }).cost).toBe(0)
})
it('charges 2 credits per company enrichment match and nothing for prospect search', () => {
expect(cost(wizaCompanyEnrichmentTool, {}, { company_name: 'Wiza' }).cost).toBeCloseTo(
2 * WIZA_CREDIT_USD
)
expect(
cost(
wizaCompanyEnrichmentTool,
{},
{ company_name: null, company_domain: null, domain: null }
).cost
).toBe(0)
expect(cost(wizaProspectSearchTool, {}, { total: 100, profiles: [] }).cost).toBe(0)
})
it('polls the reveal to completion in postProcess', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
data: {
id: 123,
status: 'finished',
email: 'a@b.com',
email_status: 'valid',
emails: [],
phones: [],
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
vi.stubGlobal('fetch', fetchMock)
const initial = {
success: true as const,
output: { id: 123, status: 'queued', is_complete: false } as any,
}
const promise = wizaIndividualRevealTool.postProcess!(
initial as any,
{ apiKey: 'k', enrichment_level: 'full' } as any,
vi.fn()
)
await vi.advanceTimersByTimeAsync(2000)
const result = await promise
expect(fetchMock).toHaveBeenCalledWith(
'https://wiza.co/api/individual_reveals/123',
expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer k' }) })
)
expect(result.success).toBe(true)
expect((result.output as any).email).toBe('a@b.com')
expect((result.output as any).status).toBe('finished')
})
it('returns immediately without polling when the initial reveal is already terminal', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const initial = {
success: true as const,
output: {
id: 123,
status: 'finished',
is_complete: true,
email: 'a@b.com',
email_status: 'valid',
emails: [],
phones: [],
} as any,
}
const result = await wizaIndividualRevealTool.postProcess!(
initial as any,
{ apiKey: 'k', enrichment_level: 'full' } as any,
vi.fn()
)
expect(fetchMock).not.toHaveBeenCalled()
expect(result.success).toBe(true)
expect((result.output as any).email).toBe('a@b.com')
})
it('retries transient poll errors and still resolves on a later finished response', async () => {
vi.useFakeTimers()
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response('busy', { status: 503 }))
.mockResolvedValueOnce(new Response('rate limited', { status: 429 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
data: {
id: 1,
status: 'finished',
email: 'a@b.com',
email_status: 'valid',
emails: [],
phones: [],
},
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
)
)
vi.stubGlobal('fetch', fetchMock)
const initial = {
success: true as const,
output: { id: 1, status: 'queued', is_complete: false } as any,
}
const promise = wizaIndividualRevealTool.postProcess!(
initial as any,
{ apiKey: 'k', enrichment_level: 'full' } as any,
vi.fn()
)
await vi.advanceTimersByTimeAsync(6000)
const result = await promise
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(result.success).toBe(true)
expect((result.output as any).email).toBe('a@b.com')
})
it('returns an explicit failure (not a queued success) after repeated poll errors', async () => {
vi.useFakeTimers()
const fetchMock = vi.fn().mockResolvedValue(new Response('error', { status: 500 }))
vi.stubGlobal('fetch', fetchMock)
const initial = {
success: true as const,
output: { id: 1, status: 'queued', is_complete: false } as any,
}
const promise = wizaIndividualRevealTool.postProcess!(
initial as any,
{ apiKey: 'k', enrichment_level: 'full' } as any,
vi.fn()
)
await vi.advanceTimersByTimeAsync(6000)
const result = await promise
expect(result.success).toBe(false)
expect((result.output as any).status).toBe('queued')
})
})
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type {
FindymailFindEmailFromLinkedInParams,
FindymailFindEmailFromLinkedInResponse,
@@ -15,6 +16,11 @@ export const findEmailFromLinkedInTool: ToolConfig<
"Find someone's email from a LinkedIn profile URL or username. Uses one finder credit when a verified email is found.",
version: '1.0.0',
hosting: findymailHosting<FindymailFindEmailFromLinkedInParams>((_params, output) => {
const contact = output.contact as { email?: string } | null
return contact?.email ? 1 : 0
}),
params: {
linkedin_url: {
type: 'string',
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type {
FindymailFindEmailFromNameParams,
FindymailFindEmailFromNameResponse,
@@ -15,6 +16,11 @@ export const findEmailFromNameTool: ToolConfig<
"Find someone's email from their name and a company domain or company name. Uses one finder credit when a verified email is found.",
version: '1.0.0',
hosting: findymailHosting<FindymailFindEmailFromNameParams>((_params, output) => {
const contact = output.contact as { email?: string } | null
return contact?.email ? 1 : 0
}),
params: {
name: {
type: 'string',
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type {
FindymailFindEmailsByDomainParams,
FindymailFindEmailsByDomainResponse,
@@ -15,6 +16,15 @@ export const findEmailsByDomainTool: ToolConfig<
'Find verified contacts at a given domain matching one or more target roles (max 3 roles). Limited to 5 concurrent synchronous requests.',
version: '1.0.0',
hosting: findymailHosting<FindymailFindEmailsByDomainParams>((_params, output) => {
// No contacts array means no verified contacts returned — no charge.
if (!Array.isArray(output.contacts)) {
return 0
}
// 1 finder credit per verified contact returned.
return output.contacts.length
}),
params: {
domain: {
type: 'string',
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type {
FindymailFindEmployeesParams,
FindymailFindEmployeesResponse,
@@ -15,6 +16,15 @@ export const findEmployeesTool: ToolConfig<
'Find employees at a company by website and target job titles. Uses 1 credit per found contact. Does not return email addresses.',
version: '1.0.0',
hosting: findymailHosting<FindymailFindEmployeesParams>((_params, output) => {
// No employees array means no contacts found — no charge.
if (!Array.isArray(output.employees)) {
return 0
}
// 1 finder credit per contact found.
return output.employees.length
}),
params: {
website: {
type: 'string',
+6
View File
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type { FindymailFindPhoneParams, FindymailFindPhoneResponse } from '@/tools/findymail/types'
import type { ToolConfig } from '@/tools/types'
@@ -8,6 +9,11 @@ export const findPhoneTool: ToolConfig<FindymailFindPhoneParams, FindymailFindPh
"Find someone's phone number from a LinkedIn profile URL. Uses 10 finder credits if a phone is found. EU citizens are excluded for legal reasons.",
version: '1.0.0',
hosting: findymailHosting<FindymailFindPhoneParams>((_params, output) => {
// Phone lookups consume 10 finder credits, only when a number is found.
return output.phone ? 10 : 0
}),
params: {
linkedin_url: {
type: 'string',
+6
View File
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type {
FindymailGetCompanyParams,
FindymailGetCompanyResponse,
@@ -11,6 +12,11 @@ export const getCompanyTool: ToolConfig<FindymailGetCompanyParams, FindymailGetC
'Retrieve company information from a LinkedIn URL, domain, or company name. Uses 1 finder credit per successful response.',
version: '1.0.0',
hosting: findymailHosting<FindymailGetCompanyParams>((_params, output) => {
// 1 finder credit per successful company match.
return output.name || output.domain ? 1 : 0
}),
params: {
linkedin_url: {
type: 'string',
+42
View File
@@ -0,0 +1,42 @@
import type { ToolHostingConfig } from '@/tools/types'
/**
* Env var prefix for Findymail hosted keys. Provide keys as
* `FINDYMAIL_API_KEY_COUNT` plus `FINDYMAIL_API_KEY_1..N`.
*/
export const FINDYMAIL_API_KEY_PREFIX = 'FINDYMAIL_API_KEY'
/**
* Dollar cost of a single Findymail finder credit.
*
* Findymail charges per verified result: 1 credit per email, 10 credits per
* phone, and only when a result is found. Estimated from the $99/month Starter
* plan (5,000 credits ≈ $0.0198/credit) — https://www.findymail.com/pricing/.
*/
export const FINDYMAIL_CREDIT_USD = 0.02
/**
* Build a Findymail `hosting` config. `getCredits` returns the number of
* Findymail credits the call consumed, derived from the tool's output (per the
* documented per-endpoint credit model at https://www.findymail.com/api/).
*/
export function findymailHosting<P>(
getCredits: (params: P, output: Record<string, unknown>) => number
): ToolHostingConfig<P> {
return {
envKeyPrefix: FINDYMAIL_API_KEY_PREFIX,
apiKeyParam: 'apiKey',
byokProviderId: 'findymail',
pricing: {
type: 'custom',
getCost: (params, output) => {
const credits = getCredits(params, output)
return { cost: credits * FINDYMAIL_CREDIT_USD, metadata: { credits } }
},
},
rateLimit: {
mode: 'per_request',
requestsPerMinute: 60,
},
}
}
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type {
FindymailLookupTechnologiesParams,
FindymailLookupTechnologiesResponse,
@@ -15,6 +16,11 @@ export const lookupTechnologiesTool: ToolConfig<
'Get the technology stack for a company by domain. Optionally filter by technology names. 1 finder credit if technologies are found, free otherwise.',
version: '1.0.0',
hosting: findymailHosting<FindymailLookupTechnologiesParams>((_params, output) => {
// 1 finder credit when a technology stack is returned, free otherwise.
return Array.isArray(output.technologies) && output.technologies.length > 0 ? 1 : 0
}),
params: {
domain: {
type: 'string',
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type {
FindymailReverseEmailLookupParams,
FindymailReverseEmailLookupResponse,
@@ -14,6 +15,13 @@ export const reverseEmailLookupTool: ToolConfig<
'Find a business profile from an email address. Uses 1 finder credit if a profile is found, 2 credits if returning full profile data.',
version: '1.0.0',
hosting: findymailHosting<FindymailReverseEmailLookupParams>((params, output) => {
const found = Boolean(output.email || output.linkedin_url || output.fullName)
if (!found) return 0
// 1 credit for a match, 2 when full profile enrichment is requested.
return params.with_profile ? 2 : 1
}),
params: {
email: {
type: 'string',
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type {
FindymailSearchTechnologiesParams,
FindymailSearchTechnologiesResponse,
@@ -15,6 +16,11 @@ export const searchTechnologiesTool: ToolConfig<
'Search the technology catalog by name. Returns up to 25 technologies. Free endpoint, rate limited to 10 requests per minute.',
version: '1.0.0',
hosting: findymailHosting<FindymailSearchTechnologiesParams>(() => {
// Free catalog search — consumes no Findymail credits.
return 0
}),
params: {
q: {
type: 'string',
+6
View File
@@ -1,3 +1,4 @@
import { findymailHosting } from '@/tools/findymail/hosting'
import type {
FindymailVerifyEmailParams,
FindymailVerifyEmailResponse,
@@ -11,6 +12,11 @@ export const verifyEmailTool: ToolConfig<FindymailVerifyEmailParams, FindymailVe
description: 'Verifies the deliverability of an email address. Uses one verifier credit.',
version: '1.0.0',
hosting: findymailHosting<FindymailVerifyEmailParams>(() => {
// Each verification consumes one verifier credit, billed at the finder-credit rate.
return 1
}),
params: {
email: {
type: 'string',
@@ -1,3 +1,4 @@
import { prospeoHosting } from '@/tools/prospeo/hosting'
import {
extractProspeoError,
type ProspeoBulkEnrichCompanyParams,
@@ -15,6 +16,14 @@ export const bulkEnrichCompanyTool: ToolConfig<
description: 'Enrich up to 50 company records at once.',
version: '1.0.0',
hosting: prospeoHosting<ProspeoBulkEnrichCompanyParams>((_params, output) => {
// Prospeo reports the exact credits spent for the batch in total_cost.
if (typeof output.total_cost !== 'number') {
throw new Error('Prospeo bulk enrich company response missing total_cost')
}
return output.total_cost
}),
params: {
apiKey: {
type: 'string',
@@ -1,3 +1,4 @@
import { prospeoHosting } from '@/tools/prospeo/hosting'
import {
extractProspeoError,
type ProspeoBulkEnrichPersonParams,
@@ -15,6 +16,14 @@ export const bulkEnrichPersonTool: ToolConfig<
description: 'Enrich up to 50 person records at once.',
version: '1.0.0',
hosting: prospeoHosting<ProspeoBulkEnrichPersonParams>((_params, output) => {
// Prospeo reports the exact credits spent for the batch in total_cost.
if (typeof output.total_cost !== 'number') {
throw new Error('Prospeo bulk enrich person response missing total_cost')
}
return output.total_cost
}),
params: {
apiKey: {
type: 'string',
+7
View File
@@ -1,3 +1,4 @@
import { prospeoHosting } from '@/tools/prospeo/hosting'
import {
extractProspeoError,
type ProspeoEnrichCompanyParams,
@@ -14,6 +15,12 @@ export const enrichCompanyTool: ToolConfig<
description: 'Enrich a company with complete B2B data.',
version: '1.0.0',
hosting: prospeoHosting<ProspeoEnrichCompanyParams>((_params, output) => {
// 1 credit per company match; no charge on a no-match or repeat enrichment.
if (output.free_enrichment === true) return 0
return output.company ? 1 : 0
}),
params: {
apiKey: {
type: 'string',
+11
View File
@@ -1,3 +1,4 @@
import { prospeoHosting } from '@/tools/prospeo/hosting'
import {
extractProspeoError,
type ProspeoEnrichPersonParams,
@@ -12,6 +13,16 @@ export const enrichPersonTool: ToolConfig<ProspeoEnrichPersonParams, ProspeoEnri
description: 'Enrich a person with complete B2B profile data, email address and mobile.',
version: '1.0.0',
hosting: prospeoHosting<ProspeoEnrichPersonParams>((_params, output) => {
// No charge on a no-match or a repeat enrichment.
if (output.free_enrichment === true) return 0
const person = output.person as Record<string, unknown> | null
if (!person) return 0
// 10 credits when a mobile is revealed, otherwise 1 for the person match.
const mobile = person.mobile as { revealed?: boolean } | undefined
return mobile?.revealed ? 10 : 1
}),
params: {
apiKey: {
type: 'string',
+43
View File
@@ -0,0 +1,43 @@
import type { ToolHostingConfig } from '@/tools/types'
/**
* Env var prefix for Prospeo hosted keys. Provide keys as
* `PROSPEO_API_KEY_COUNT` plus `PROSPEO_API_KEY_1..N`.
*/
export const PROSPEO_API_KEY_PREFIX = 'PROSPEO_API_KEY'
/**
* Dollar cost of a single Prospeo credit.
*
* Prospeo charges per match: 1 credit per person/company match, 10 credits when
* a mobile is revealed, and never on a no-match or a repeat enrichment. Based on
* the $39/month Starter plan (1,000 credits ≈ $0.039/credit) — https://prospeo.io/pricing.
*/
export const PROSPEO_CREDIT_USD = 0.039
/**
* Build a Prospeo `hosting` config. `getCredits` returns the number of Prospeo
* credits the call consumed, derived from the tool's output (prefer the
* API-reported `total_cost` for bulk endpoints; otherwise compute from the
* `free`/`free_enrichment` flag and the match).
*/
export function prospeoHosting<P>(
getCredits: (params: P, output: Record<string, unknown>) => number
): ToolHostingConfig<P> {
return {
envKeyPrefix: PROSPEO_API_KEY_PREFIX,
apiKeyParam: 'apiKey',
byokProviderId: 'prospeo',
pricing: {
type: 'custom',
getCost: (params, output) => {
const credits = getCredits(params, output)
return { cost: credits * PROSPEO_CREDIT_USD, metadata: { credits } }
},
},
rateLimit: {
mode: 'per_request',
requestsPerMinute: 60,
},
}
}
+8
View File
@@ -1,3 +1,4 @@
import { prospeoHosting } from '@/tools/prospeo/hosting'
import {
extractProspeoError,
PROSPEO_PAGINATION_OUTPUT,
@@ -16,6 +17,13 @@ export const searchCompanyTool: ToolConfig<
description: 'Search for companies using 20+ filters to build account lists.',
version: '1.0.0',
hosting: prospeoHosting<ProspeoSearchCompanyParams>((_params, output) => {
// 1 credit per page that returns at least one result; free on 30-day dedup.
if (output.free === true) return 0
const results = output.results
return Array.isArray(results) && results.length > 0 ? 1 : 0
}),
params: {
apiKey: {
type: 'string',
+8
View File
@@ -1,3 +1,4 @@
import { prospeoHosting } from '@/tools/prospeo/hosting'
import {
extractProspeoError,
PROSPEO_PAGINATION_OUTPUT,
@@ -14,6 +15,13 @@ export const searchPersonTool: ToolConfig<ProspeoSearchPersonParams, ProspeoSear
description: 'Search for leads using 20+ filters to build targeted contact lists.',
version: '1.0.0',
hosting: prospeoHosting<ProspeoSearchPersonParams>((_params, output) => {
// 1 credit per page that returns at least one result; free on 30-day dedup.
if (output.free === true) return 0
const results = output.results
return Array.isArray(results) && results.length > 0 ? 1 : 0
}),
params: {
apiKey: {
type: 'string',
+2 -4
View File
@@ -3053,9 +3053,8 @@ import {
import {
wizaCompanyEnrichmentTool,
wizaGetCreditsTool,
wizaGetIndividualRevealTool,
wizaIndividualRevealTool,
wizaProspectSearchTool,
wizaStartIndividualRevealTool,
} from '@/tools/wiza'
import {
wordpressCreateCategoryTool,
@@ -5436,9 +5435,8 @@ export const tools: Record<string, ToolConfig> = {
wikipedia_random: wikipediaRandomPageTool,
wiza_company_enrichment: wizaCompanyEnrichmentTool,
wiza_get_credits: wizaGetCreditsTool,
wiza_get_individual_reveal: wizaGetIndividualRevealTool,
wiza_individual_reveal: wizaIndividualRevealTool,
wiza_prospect_search: wizaProspectSearchTool,
wiza_start_individual_reveal: wizaStartIndividualRevealTool,
wordpress_create_post: wordpressCreatePostTool,
wordpress_update_post: wordpressUpdatePostTool,
wordpress_delete_post: wordpressDeletePostTool,
+4 -1
View File
@@ -21,6 +21,9 @@ export type BYOKProviderId =
| 'cohere'
| 'hunter'
| 'peopledatalabs'
| 'findymail'
| 'prospeo'
| 'wiza'
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD'
@@ -309,7 +312,7 @@ export type ToolHostingPricing<P = Record<string, unknown>> = PerRequestPricing
* Adding more keys only requires updating the count and adding the new env var —
* no code changes needed.
*/
interface ToolHostingConfig<P = Record<string, unknown>> {
export interface ToolHostingConfig<P = Record<string, unknown>> {
/** Optional predicate for tools where hosted keys only apply to some parameter combinations. */
enabled?: (params: P) => boolean
/**
@@ -1,4 +1,5 @@
import type { ToolConfig } from '@/tools/types'
import { wizaHosting } from '@/tools/wiza/hosting'
import type { WizaCompanyEnrichmentParams, WizaCompanyEnrichmentResponse } from '@/tools/wiza/types'
export const wizaCompanyEnrichmentTool: ToolConfig<
@@ -11,6 +12,11 @@ export const wizaCompanyEnrichmentTool: ToolConfig<
'Enrich a company by name, domain, LinkedIn ID, or LinkedIn slug with detailed firmographic data',
version: '1.0.0',
hosting: wizaHosting<WizaCompanyEnrichmentParams>((_params, output) => {
// 2 API credits per successful company match; no charge on a no-match.
return output.company_name || output.company_domain || output.domain ? 2 : 0
}),
params: {
apiKey: {
type: 'string',
@@ -1,176 +0,0 @@
import type { ToolConfig } from '@/tools/types'
import type {
WizaGetIndividualRevealParams,
WizaGetIndividualRevealResponse,
} from '@/tools/wiza/types'
export const wizaGetIndividualRevealTool: ToolConfig<
WizaGetIndividualRevealParams,
WizaGetIndividualRevealResponse
> = {
id: 'wiza_get_individual_reveal',
name: 'Wiza Get Individual Reveal',
description: 'Retrieve the status and enriched data for an individual reveal by ID',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Wiza API key',
},
id: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Individual reveal ID returned from Start Individual Reveal',
},
},
request: {
url: (params: WizaGetIndividualRevealParams) =>
`https://wiza.co/api/individual_reveals/${encodeURIComponent(String(params.id).trim())}`,
method: 'GET',
headers: (params: WizaGetIndividualRevealParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Wiza API error: ${response.status} - ${errorText}`)
}
const json = await response.json()
const d = json.data ?? {}
const emails = Array.isArray(d.emails) ? d.emails : []
const phones = Array.isArray(d.phones) ? d.phones : []
return {
success: true,
output: {
id: d.id ?? null,
status: d.status ?? null,
is_complete: d.is_complete ?? null,
name: d.name ?? null,
company: d.company ?? null,
enrichment_level: d.enrichment_level ?? null,
linkedin_profile_url: d.linkedin_profile_url ?? null,
title: d.title ?? null,
location: d.location ?? null,
email: d.email ?? null,
email_type: d.email_type ?? null,
email_status: d.email_status ?? null,
emails: emails.map((e: Record<string, unknown>) => ({
email: (e.email as string) ?? null,
email_type: (e.email_type as string) ?? null,
email_status: (e.email_status as string) ?? null,
})),
mobile_phone: d.mobile_phone ?? null,
phone_number: d.phone_number ?? null,
phone_status: d.phone_status ?? null,
phones: phones.map((p: Record<string, unknown>) => ({
number: (p.number as string) ?? null,
pretty_number: (p.pretty_number as string) ?? null,
type: (p.type as string) ?? null,
})),
company_size: d.company_size ?? null,
company_size_range: d.company_size_range ?? null,
company_type: d.company_type ?? null,
company_domain: d.company_domain ?? null,
company_locality: d.company_locality ?? null,
company_region: d.company_region ?? null,
company_country: d.company_country ?? null,
company_street: d.company_street ?? null,
company_postal_code: d.company_postal_code ?? null,
company_founded: d.company_founded ?? null,
company_funding: d.company_funding ?? null,
company_revenue: d.company_revenue ?? null,
company_industry: d.company_industry ?? null,
company_subindustry: d.company_subindustry ?? null,
company_linkedin: d.company_linkedin ?? null,
company_location: d.company_location ?? null,
company_description: d.company_description ?? null,
credits: d.credits ?? null,
},
}
},
outputs: {
id: { type: 'number', description: 'Reveal ID', optional: true },
status: {
type: 'string',
description: 'queued | resolving | finished | failed',
optional: true,
},
is_complete: {
type: 'boolean',
description: 'Whether the reveal has completed',
optional: true,
},
name: { type: 'string', description: 'Full name', optional: true },
company: { type: 'string', description: 'Company name', optional: true },
enrichment_level: { type: 'string', description: 'Enrichment level used', optional: true },
linkedin_profile_url: { type: 'string', description: 'LinkedIn URL', optional: true },
title: { type: 'string', description: 'Job title', optional: true },
location: { type: 'string', description: 'Location', optional: true },
email: { type: 'string', description: 'Primary email', optional: true },
email_type: { type: 'string', description: 'Email type', optional: true },
email_status: { type: 'string', description: 'valid | risky | unfound', optional: true },
emails: {
type: 'array',
description: 'All emails found',
optional: true,
items: {
type: 'object',
properties: {
email: { type: 'string' },
email_type: { type: 'string' },
email_status: { type: 'string' },
},
},
},
mobile_phone: { type: 'string', description: 'Mobile phone', optional: true },
phone_number: { type: 'string', description: 'Direct/office phone', optional: true },
phone_status: { type: 'string', description: 'found | unfound', optional: true },
phones: {
type: 'array',
description: 'All phones found',
optional: true,
items: {
type: 'object',
properties: {
number: { type: 'string' },
pretty_number: { type: 'string' },
type: { type: 'string' },
},
},
},
company_size: { type: 'number', description: 'Employee count', optional: true },
company_size_range: { type: 'string', description: 'Headcount range', optional: true },
company_type: { type: 'string', description: 'Company type', optional: true },
company_domain: { type: 'string', description: 'Company domain', optional: true },
company_locality: { type: 'string', description: 'City', optional: true },
company_region: { type: 'string', description: 'State/region', optional: true },
company_country: { type: 'string', description: 'Country', optional: true },
company_street: { type: 'string', description: 'Street', optional: true },
company_postal_code: { type: 'string', description: 'Postal code', optional: true },
company_founded: { type: 'number', description: 'Year founded', optional: true },
company_funding: { type: 'string', description: 'Funding total', optional: true },
company_revenue: { type: 'string', description: 'Revenue', optional: true },
company_industry: { type: 'string', description: 'Industry', optional: true },
company_subindustry: { type: 'string', description: 'Subindustry', optional: true },
company_linkedin: { type: 'string', description: 'Company LinkedIn URL', optional: true },
company_location: { type: 'string', description: 'Full company location', optional: true },
company_description: { type: 'string', description: 'Company description', optional: true },
credits: {
type: 'json',
description:
'Credits deducted for this reveal (api_credits: { total, email_credits, phone_credits, scrape_credits })',
optional: true,
},
},
}
+42
View File
@@ -0,0 +1,42 @@
import type { ToolHostingConfig } from '@/tools/types'
/**
* Env var prefix for Wiza hosted keys. Provide keys as `WIZA_API_KEY_COUNT`
* plus `WIZA_API_KEY_1..N`.
*/
export const WIZA_API_KEY_PREFIX = 'WIZA_API_KEY'
/**
* Dollar cost of a single Wiza API credit.
*
* Wiza meters API usage in credits at a documented $0.025/credit (2,000-credit
* minimum) — https://help.wiza.co/en/articles/13551713-how-to-purchase-api-credits.
* Credits are deducted only when data is successfully returned: 2 credits per
* valid email, 5 credits per phone, 2 credits per company enrichment.
*/
export const WIZA_CREDIT_USD = 0.025
/**
* Build a Wiza `hosting` config. `getCredits` returns the number of Wiza API
* credits the call consumed, derived from the tool's output.
*/
export function wizaHosting<P>(
getCredits: (params: P, output: Record<string, unknown>) => number
): ToolHostingConfig<P> {
return {
envKeyPrefix: WIZA_API_KEY_PREFIX,
apiKeyParam: 'apiKey',
byokProviderId: 'wiza',
pricing: {
type: 'custom',
getCost: (params, output) => {
const credits = getCredits(params, output)
return { cost: credits * WIZA_CREDIT_USD, metadata: { credits } }
},
},
rateLimit: {
mode: 'per_request',
requestsPerMinute: 60,
},
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
export { wizaCompanyEnrichmentTool } from './company_enrichment'
export { wizaGetCreditsTool } from './get_credits'
export { wizaGetIndividualRevealTool } from './get_individual_reveal'
export { wizaIndividualRevealTool } from './individual_reveal'
export { wizaProspectSearchTool } from './prospect_search'
export { wizaStartIndividualRevealTool } from './start_individual_reveal'
export type * from './types'
+330
View File
@@ -0,0 +1,330 @@
import { sleep } from '@sim/utils/helpers'
import type { ToolConfig } from '@/tools/types'
import { wizaHosting } from '@/tools/wiza/hosting'
import type {
WizaIndividualRevealData,
WizaIndividualRevealParams,
WizaIndividualRevealResponse,
} from '@/tools/wiza/types'
const POLL_INTERVAL_MS = 2000
const MAX_POLL_TIME_MS = 120000
/** Tolerate brief Wiza outages while polling before giving up on an already-started reveal. */
const MAX_CONSECUTIVE_POLL_ERRORS = 3
/** Whether a reveal payload has reached a terminal state and no longer needs polling. */
function isTerminalReveal(d: { status?: string | null; is_complete?: boolean | null }): boolean {
return d.status === 'finished' || d.status === 'failed' || d.is_complete === true
}
/** Map a Wiza individual-reveal payload (`data` object) to the tool output shape. */
function mapRevealData(d: Record<string, unknown>): WizaIndividualRevealData {
const emails = Array.isArray(d.emails) ? (d.emails as Record<string, unknown>[]) : []
const phones = Array.isArray(d.phones) ? (d.phones as Record<string, unknown>[]) : []
return {
id: (d.id as number) ?? null,
status: (d.status as string) ?? null,
is_complete: (d.is_complete as boolean) ?? null,
name: (d.name as string) ?? null,
company: (d.company as string) ?? null,
enrichment_level: (d.enrichment_level as string) ?? null,
linkedin_profile_url: (d.linkedin_profile_url as string) ?? null,
title: (d.title as string) ?? null,
location: (d.location as string) ?? null,
email: (d.email as string) ?? null,
email_type: (d.email_type as string) ?? null,
email_status: (d.email_status as string) ?? null,
emails: emails.map((e) => ({
email: (e.email as string) ?? null,
email_type: (e.email_type as string) ?? null,
email_status: (e.email_status as string) ?? null,
})),
mobile_phone: (d.mobile_phone as string) ?? null,
phone_number: (d.phone_number as string) ?? null,
phone_status: (d.phone_status as string) ?? null,
phones: phones.map((p) => ({
number: (p.number as string) ?? null,
pretty_number: (p.pretty_number as string) ?? null,
type: (p.type as string) ?? null,
})),
company_size: (d.company_size as number) ?? null,
company_size_range: (d.company_size_range as string) ?? null,
company_type: (d.company_type as string) ?? null,
company_domain: (d.company_domain as string) ?? null,
company_locality: (d.company_locality as string) ?? null,
company_region: (d.company_region as string) ?? null,
company_country: (d.company_country as string) ?? null,
company_street: (d.company_street as string) ?? null,
company_postal_code: (d.company_postal_code as string) ?? null,
company_founded: (d.company_founded as number) ?? null,
company_funding: (d.company_funding as string) ?? null,
company_revenue: (d.company_revenue as string) ?? null,
company_industry: (d.company_industry as string) ?? null,
company_subindustry: (d.company_subindustry as string) ?? null,
company_linkedin: (d.company_linkedin as string) ?? null,
company_location: (d.company_location as string) ?? null,
company_description: (d.company_description as string) ?? null,
credits: (d.credits as Record<string, unknown>) ?? null,
}
}
export const wizaIndividualRevealTool: ToolConfig<
WizaIndividualRevealParams,
WizaIndividualRevealResponse
> = {
id: 'wiza_individual_reveal',
name: 'Wiza Individual Reveal',
description:
'Reveal a contact via LinkedIn URL, name + company/domain, or email. Starts the reveal and polls until it resolves. Uses 2 credits per valid email and 5 credits per phone, charged only on success.',
version: '1.0.0',
hosting: wizaHosting<WizaIndividualRevealParams>((_params, output) => {
let credits = 0
const emails = Array.isArray(output.emails)
? (output.emails as { email_status?: string }[])
: []
const emailValid =
output.email_status === 'valid' || emails.some((e) => e.email_status === 'valid')
// 2 credits when at least one valid email is returned.
if (emailValid) credits += 2
const phones = Array.isArray(output.phones) ? output.phones : []
const phoneFound = Boolean(output.mobile_phone || output.phone_number || phones.length > 0)
// 5 credits when at least one phone is returned.
if (phoneFound) credits += 5
return credits
}),
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Wiza API key',
},
enrichment_level: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Enrichment depth: none, partial, phone, or full',
},
profile_url: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'LinkedIn profile URL (e.g., https://linkedin.com/in/johndoe)',
},
full_name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Full name (used with company or domain)',
},
company: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company name (used with full_name)',
},
domain: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company domain (used with full_name)',
},
email: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Email address (use alone or with other identifiers)',
},
accept_work: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to accept work emails (email_options)',
},
accept_personal: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to accept personal emails (email_options)',
},
},
request: {
url: 'https://wiza.co/api/individual_reveals',
method: 'POST',
headers: (params: WizaIndividualRevealParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: WizaIndividualRevealParams) => {
const individual: Record<string, unknown> = {}
if (params.profile_url) individual.profile_url = params.profile_url
if (params.full_name) individual.full_name = params.full_name
if (params.company) individual.company = params.company
if (params.domain) individual.domain = params.domain
if (params.email) individual.email = params.email
const body: Record<string, unknown> = {
individual_reveal: individual,
enrichment_level: params.enrichment_level,
}
if (params.accept_work !== undefined || params.accept_personal !== undefined) {
const emailOptions: Record<string, unknown> = {}
if (params.accept_work !== undefined) emailOptions.accept_work = params.accept_work
if (params.accept_personal !== undefined) {
emailOptions.accept_personal = params.accept_personal
}
body.email_options = emailOptions
}
return body
},
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Wiza API error: ${response.status} - ${errorText}`)
}
const json = await response.json()
return {
success: true,
output: mapRevealData(json.data ?? {}),
}
},
postProcess: async (result, params) => {
if (!result.success) return result
// Wiza can resolve synchronously (e.g. a cache hit) — the initial POST payload is
// already mapped, so skip polling when it is terminal.
if (isTerminalReveal(result.output)) {
return { success: result.output.status !== 'failed', output: result.output }
}
const revealId = result.output.id
if (revealId == null) {
// Return an explicit failure rather than throwing: a thrown error here is swallowed
// by the executor and masked as the queued (incomplete) success result.
return {
success: false,
error: 'Wiza individual reveal did not return an id',
output: result.output,
}
}
let elapsedTime = 0
let consecutiveErrors = 0
while (elapsedTime < MAX_POLL_TIME_MS) {
await sleep(POLL_INTERVAL_MS)
elapsedTime += POLL_INTERVAL_MS
const statusResponse = await fetch(
`https://wiza.co/api/individual_reveals/${encodeURIComponent(String(revealId))}`,
{
headers: {
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
},
}
)
if (!statusResponse.ok) {
// The reveal is already started (and billed by Wiza), so tolerate brief outages and
// retry rather than aborting the whole window on a single transient 5xx/429.
consecutiveErrors += 1
if (consecutiveErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
const errorText = await statusResponse.text().catch(() => '')
return {
success: false,
error: `Wiza API error: ${statusResponse.status} - ${errorText}`,
output: result.output,
}
}
continue
}
consecutiveErrors = 0
const json = await statusResponse.json()
const data = json.data ?? {}
if (isTerminalReveal(data)) {
return {
success: data.status !== 'failed',
output: mapRevealData(data),
}
}
}
return {
success: false,
error: 'Wiza individual reveal did not complete within the polling window',
output: result.output,
}
},
outputs: {
id: { type: 'number', description: 'Reveal ID' },
status: { type: 'string', description: 'queued | resolving | finished | failed' },
is_complete: { type: 'boolean', description: 'Whether the reveal has completed' },
name: { type: 'string', description: 'Full name', optional: true },
company: { type: 'string', description: 'Company name', optional: true },
enrichment_level: { type: 'string', description: 'Enrichment level used', optional: true },
linkedin_profile_url: { type: 'string', description: 'LinkedIn URL', optional: true },
title: { type: 'string', description: 'Job title', optional: true },
location: { type: 'string', description: 'Location', optional: true },
email: { type: 'string', description: 'Primary email', optional: true },
email_type: { type: 'string', description: 'Email type', optional: true },
email_status: { type: 'string', description: 'valid | risky | unfound', optional: true },
emails: {
type: 'array',
description: 'All emails found',
optional: true,
items: {
type: 'object',
properties: {
email: { type: 'string' },
email_type: { type: 'string' },
email_status: { type: 'string' },
},
},
},
mobile_phone: { type: 'string', description: 'Mobile phone', optional: true },
phone_number: { type: 'string', description: 'Direct/office phone', optional: true },
phone_status: { type: 'string', description: 'found | unfound', optional: true },
phones: {
type: 'array',
description: 'All phones found',
optional: true,
items: {
type: 'object',
properties: {
number: { type: 'string' },
pretty_number: { type: 'string' },
type: { type: 'string' },
},
},
},
company_size: { type: 'number', description: 'Employee count', optional: true },
company_size_range: { type: 'string', description: 'Headcount range', optional: true },
company_type: { type: 'string', description: 'Company type', optional: true },
company_domain: { type: 'string', description: 'Company domain', optional: true },
company_locality: { type: 'string', description: 'City', optional: true },
company_region: { type: 'string', description: 'State/region', optional: true },
company_country: { type: 'string', description: 'Country', optional: true },
company_street: { type: 'string', description: 'Street', optional: true },
company_postal_code: { type: 'string', description: 'Postal code', optional: true },
company_founded: { type: 'number', description: 'Year founded', optional: true },
company_funding: { type: 'string', description: 'Funding total', optional: true },
company_revenue: { type: 'string', description: 'Revenue', optional: true },
company_industry: { type: 'string', description: 'Industry', optional: true },
company_subindustry: { type: 'string', description: 'Subindustry', optional: true },
company_linkedin: { type: 'string', description: 'Company LinkedIn URL', optional: true },
company_location: { type: 'string', description: 'Full company location', optional: true },
company_description: { type: 'string', description: 'Company description', optional: true },
credits: { type: 'json', description: 'Credits consumed by the reveal', optional: true },
},
}
+7
View File
@@ -1,4 +1,5 @@
import type { ToolConfig } from '@/tools/types'
import { wizaHosting } from '@/tools/wiza/hosting'
import type { WizaProspectSearchParams, WizaProspectSearchResponse } from '@/tools/wiza/types'
export const wizaProspectSearchTool: ToolConfig<
@@ -10,6 +11,12 @@ export const wizaProspectSearchTool: ToolConfig<
description: "Search Wiza's database of prospects using person, company, and financial filters",
version: '1.0.0',
hosting: wizaHosting<WizaProspectSearchParams>(() => {
// Prospect search returns profiles without contact data and consumes no credits;
// Wiza charges only on reveal/enrichment.
return 0
}),
params: {
apiKey: {
type: 'string',
@@ -1,151 +0,0 @@
import type { ToolConfig } from '@/tools/types'
import type {
WizaStartIndividualRevealParams,
WizaStartIndividualRevealResponse,
} from '@/tools/wiza/types'
export const wizaStartIndividualRevealTool: ToolConfig<
WizaStartIndividualRevealParams,
WizaStartIndividualRevealResponse
> = {
id: 'wiza_start_individual_reveal',
name: 'Wiza Start Individual Reveal',
description:
'Start an individual reveal to enrich a contact via LinkedIn URL, name+company, or email',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Wiza API key',
},
enrichment_level: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'Enrichment depth: none, partial, phone, or full',
},
profile_url: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'LinkedIn profile URL (e.g., https://linkedin.com/in/johndoe)',
},
full_name: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Full name (used with company or domain)',
},
company: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company name (used with full_name)',
},
domain: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Company domain (used with full_name)',
},
email: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Email address (use alone or with other identifiers)',
},
accept_work: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to accept work emails (email_options)',
},
accept_personal: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'Whether to accept personal emails (email_options)',
},
callback_url: {
type: 'string',
required: false,
visibility: 'user-or-llm',
description: 'Optional URL to receive a callback with the reveal update',
},
},
request: {
url: 'https://wiza.co/api/individual_reveals',
method: 'POST',
headers: (params: WizaStartIndividualRevealParams) => ({
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params: WizaStartIndividualRevealParams) => {
const individual: Record<string, unknown> = {}
if (params.profile_url) individual.profile_url = params.profile_url
if (params.full_name) individual.full_name = params.full_name
if (params.company) individual.company = params.company
if (params.domain) individual.domain = params.domain
if (params.email) individual.email = params.email
const body: Record<string, unknown> = {
individual_reveal: individual,
enrichment_level: params.enrichment_level,
}
if (params.accept_work !== undefined || params.accept_personal !== undefined) {
const emailOptions: Record<string, unknown> = {}
if (params.accept_work !== undefined) emailOptions.accept_work = params.accept_work
if (params.accept_personal !== undefined) {
emailOptions.accept_personal = params.accept_personal
}
body.email_options = emailOptions
}
if (params.callback_url) body.callback_url = params.callback_url
return body
},
},
transformResponse: async (response: Response) => {
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Wiza API error: ${response.status} - ${errorText}`)
}
const json = await response.json()
const d = json.data ?? {}
return {
success: true,
output: {
id: d.id ?? null,
status: d.status ?? null,
is_complete: d.is_complete ?? null,
},
}
},
outputs: {
id: {
type: 'number',
description: 'Individual reveal ID (use with Get Individual Reveal)',
optional: true,
},
status: {
type: 'string',
description: 'Reveal status: queued, resolving, finished, or failed',
optional: true,
},
is_complete: {
type: 'boolean',
description: 'Whether the reveal has completed',
optional: true,
},
},
}
+4 -19
View File
@@ -105,7 +105,7 @@ export interface WizaCompanyEnrichmentResponse extends ToolResponse {
}
}
export interface WizaStartIndividualRevealParams {
export interface WizaIndividualRevealParams {
apiKey: string
enrichment_level: 'none' | 'partial' | 'phone' | 'full'
profile_url?: string
@@ -115,10 +115,9 @@ export interface WizaStartIndividualRevealParams {
email?: string
accept_work?: boolean
accept_personal?: boolean
callback_url?: string
}
interface WizaIndividualRevealData {
export interface WizaIndividualRevealData {
id: number | null
status: string | null
is_complete: boolean | null
@@ -164,20 +163,7 @@ interface WizaIndividualRevealData {
credits: Record<string, unknown> | null
}
export interface WizaStartIndividualRevealResponse extends ToolResponse {
output: {
id: number | null
status: string | null
is_complete: boolean | null
}
}
export interface WizaGetIndividualRevealParams {
apiKey: string
id: string
}
export interface WizaGetIndividualRevealResponse extends ToolResponse {
export interface WizaIndividualRevealResponse extends ToolResponse {
output: WizaIndividualRevealData
}
@@ -185,5 +171,4 @@ export type WizaResponse =
| WizaGetCreditsResponse
| WizaProspectSearchResponse
| WizaCompanyEnrichmentResponse
| WizaStartIndividualRevealResponse
| WizaGetIndividualRevealResponse
| WizaIndividualRevealResponse