From 0614a70cb890a466d4ea69bee219953eff266f46 Mon Sep 17 00:00:00 2001 From: milstan Date: Thu, 14 May 2026 20:23:24 -0700 Subject: [PATCH 1/2] Add agentic Leadbay file import flow --- CHANGELOG.md | 9 + .../core/src/composite/import-and-qualify.ts | 14 +- packages/core/src/composite/import-leads.ts | 46 +- .../core/src/composite/resolve-import-rows.ts | 474 ++++++++++++++++++ packages/core/src/index.ts | 13 +- packages/core/src/tools/add-note.ts | 3 +- .../core/src/tools/create-custom-field.ts | 146 ++++++ .../core/src/tools/list-mappable-fields.ts | 18 +- packages/core/src/types.ts | 42 ++ .../test/unit/composite/import-leads.test.ts | 28 ++ .../composite/resolve-import-rows.test.ts | 243 +++++++++ .../unit/tools/create-custom-field.test.ts | 123 +++++ packages/mcp/CHANGELOG.md | 4 + packages/mcp/src/prompts.ts | 37 ++ packages/mcp/src/server.ts | 1 + .../test/output-schema-conformance.test.ts | 51 +- packages/mcp/test/prompts.test.ts | 24 +- 17 files changed, 1256 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/composite/resolve-import-rows.ts create mode 100644 packages/core/src/tools/create-custom-field.ts create mode 100644 packages/core/test/unit/composite/resolve-import-rows.test.ts create mode 100644 packages/core/test/unit/tools/create-custom-field.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bb04a43..01c52f31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ P1 / P2 / P3 priorities from the comprehensive eval doc. ### Spec primitive coverage +- **Agentic file-import resolver.** New `leadbay_resolve_import_rows` + wraps backend `POST /leads/resolve` for messy CSV-shaped user data, + returns matched / ambiguous / unresolved candidates, optionally + hydrates ambiguous candidates with active-lens profile facts, and emits + `records_for_import` + safe identity-only `mappings_for_import` for the + standard import and import-and-qualify composites. Import mappings now + accept `LEADBAY_ID`, `CRM_ID`, and `SIREN` as resolver keys in addition + to name / website. A new `leadbay_import_file` prompt teaches the full + inspect → map → resolve → disambiguate → import / qualify workflow. - **Tool annotations on every tool (spec MCP 2025-11-25 §Tools).** Each tool now declares `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`, plus a short `title`, so MCP clients (Claude Desktop, diff --git a/packages/core/src/composite/import-and-qualify.ts b/packages/core/src/composite/import-and-qualify.ts index e0035046..e95f6626 100644 --- a/packages/core/src/composite/import-and-qualify.ts +++ b/packages/core/src/composite/import-and-qualify.ts @@ -318,7 +318,13 @@ export const importAndQualify: Tool< " - `domains`: list of `{domain, name?}` (Mode A) — mutually exclusive with `records`.\n" + " - `records`: list of CSV-shaped objects (Mode B), accompanied by `mappings`. Use `mappings.fields` with " + " StandardCrmFieldType names or 'CUSTOM.' wire values; or `mappings.custom_fields` with field id " + - " or name shorthand. Discover the org's mappable surface via leadbay_list_mappable_fields.\n" + + " or name shorthand. At least one mapped field must be LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or " + + " LEAD_WEBSITE. For messy files, inspect every column and sample values, build a preservation plan, call leadbay_resolve_import_rows, and pass its " + + " records_for_import / mappings_for_import here. Discover the org's mappable surface via " + + " leadbay_list_mappable_fields. For each meaningful column decide standard field, CONTACT_* field, Leadbay note, custom field, derived helper, or skip with a reason; create/reuse custom fields for meaningful data with no standard field. For contact exports and embedded owner/contact lists, map parent company identity plus CONTACT_* fields; " + + " expand structured owners, decision makers, or contacts into additional rows that repeat the parent lead identity and import one person per row. Repeated company/LEADBAY_ID rows import as multiple contacts. If there is no company website, derive " + + " one from business contact email domains only when they agree with company/deal/brand context; ignore consumer mailbox and conflicting POS/vendor/group domains. Preserve HubSpot/source " + + " links by reusing or creating a custom field with leadbay_create_custom_field; reuse existing HubSpot linked-id fields and preserve raw source identifiers such as hubspot_id and associated_deal when meaningful. Keep meaningful per-lead notes/context aside and write them with leadbay_add_note after the import returns lead IDs; for dry runs, report which notes would be written. For ambiguous rows, work to disambiguate with hydrated candidate profiles, exact phone/domain/registry/CRM id, and street-level location; if several candidates share a website, use location/phone/source URL path to pick the specific branch when exactly one matches.\n" + ` - Budgets: \`total_budget_ms\` (default ${DEFAULT_TOTAL_BUDGET_MS / 60_000} min) caps the entire wall-clock; ` + ` \`per_lead_budget_ms\` (default ${DEFAULT_PER_LEAD_BUDGET_MS / 1_000}s) caps each lead's individual qualification poll.\n\n` + "Outputs include `qualified[]` (per-lead question answers), `still_running[]` (lead ids whose qualification " + @@ -404,7 +410,11 @@ export const importAndQualify: Tool< description: "Object whose keys are CSV column names and whose values are either StandardCrmFieldType " + "(LEAD_NAME, LEAD_WEBSITE, ..., CONTACT_TITLE) or 'CUSTOM.'. Discover via " + - "leadbay_list_mappable_fields. At least one entry must target LEAD_NAME or LEAD_WEBSITE.", + "leadbay_list_mappable_fields. At least one entry must target LEADBAY_ID, CRM_ID, SIREN, " + + "LEAD_NAME, or LEAD_WEBSITE. Use leadbay_resolve_import_rows to prepare LEADBAY_ID values " + + "from messy user files. Contact exports and embedded owner/contact lists should map CONTACT_EMAIL/PHONE/TITLE/name fields " + + "while preserving parent lead identity; expand structured people into repeated parent rows. HubSpot/source links should map to CUSTOM. " + + "fields created or discovered before import.", }, custom_fields: { type: "object", diff --git a/packages/core/src/composite/import-leads.ts b/packages/core/src/composite/import-leads.ts index 49dfe94e..d941bed0 100644 --- a/packages/core/src/composite/import-leads.ts +++ b/packages/core/src/composite/import-leads.ts @@ -124,6 +124,13 @@ const RESERVED_COLUMN_RE = /^mcp_row_id$/i; // "CUSTOM." followed by the bigint id of a row in the org's custom-fields // table. Anything else under the same prefix would 400 server-side. const CUSTOM_FIELD_RE = /^CUSTOM\.(\d+)$/; +const IMPORT_RESOLVER_FIELDS = new Set([ + "LEADBAY_ID", + "CRM_ID", + "LEAD_NAME", + "LEAD_WEBSITE", + "SIREN", +]); export function isCustomFieldMappingValue(v: string): v is `CUSTOM.${number}` { return CUSTOM_FIELD_RE.test(v); @@ -485,13 +492,16 @@ function prepareRecordsMode( ); } - // Resolver requires LEAD_NAME or LEAD_WEBSITE for the wizard to find leads. + // Resolver requires at least one deterministic or fuzzy identity key for + // the wizard to find leads. LEADBAY_ID and CRM_ID short-circuit, SIREN + // resolves via registry, and LEAD_NAME/LEAD_WEBSITE are the fuzzy/common + // file-import paths. const targets = new Set(fieldEntries.map(([, v]) => v)); - if (!targets.has("LEAD_NAME") && !targets.has("LEAD_WEBSITE")) { + if (![...targets].some((t) => IMPORT_RESOLVER_FIELDS.has(t))) { throw client.makeError( "IMPORT_MAPPING_NO_RESOLVER", - "mappings.fields must include LEAD_NAME or LEAD_WEBSITE", - "The wizard needs at least one of those fields to match a lead. Map a CSV column to one of them.", + "mappings.fields must include LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE", + "The wizard needs at least one identity field to match a lead. Use leadbay_resolve_import_rows to prepare LEADBAY_ID values when the input file is messy.", "POST /imports" ); } @@ -1272,8 +1282,8 @@ export const importLeads: Tool = { "not_imported: [{domain, reason}], importIds, _meta }.\n" + " B) Custom records + mapping — pass `records: [{Col1, Col2, ...}]` plus `mappings.fields: {Col1: 'LEAD_NAME', Col2: 'LEAD_WEBSITE', ...}`. " + "The tool synthesizes a CSV from the union of record keys (deterministic order) and POSTs the " + - "caller-supplied mapping to the wizard. mappings.fields must include LEAD_NAME or LEAD_WEBSITE " + - "(the resolver needs at least one). Output: { leads: [{rowId, domain?, leadId, name}], " + + "caller-supplied mapping to the wizard. mappings.fields must include LEADBAY_ID, CRM_ID, SIREN, " + + "LEAD_NAME, or LEAD_WEBSITE (the resolver needs at least one identity key). Output: { leads: [{rowId, domain?, leadId, name}], " + "not_imported: [{rowId, domain?, reason}], importIds, _meta }. `rowId` round-trips your input order.\n\n" + "Pass exactly one of `domains` / `records`. Reserved column MCP_ROW_ID (any case) cannot appear in " + "records or mappings — the tool injects it for stable reconciliation.\n\n" + @@ -1289,10 +1299,24 @@ export const importLeads: Tool = { "columns (sector, location, status, etc.) and want to drive the wizard with explicit field mappings.\n" + "When NOT to use: for prospect discovery (use leadbay_pull_leads); for one specific company's " + "profile (use leadbay_research_company); when you can't tolerate the side effects above.\n\n" + + "For messy user files, call leadbay_resolve_import_rows first. It uses the backend resolver to add " + + "LEADBAY_ID for deterministic matches and returns records_for_import/mappings_for_import you can pass here. " + + "Agents should still inspect every column and sample values, build a preservation plan, and pass an explicit final mapping; column names vary too much to rely on fixed guesses. " + + "For each meaningful column decide standard field, CONTACT_* field, Leadbay note, custom field, derived helper, or skip with a reason. Default to preserving client-provided business data; for meaningful columns with no standard field, create/reuse custom fields instead of silently dropping them. " + + "For contact-only exports, derive a company-domain column from CONTACT_EMAIL only when the email uses a real business domain " + + "(not gmail/hotmail/outlook/yahoo/icloud/proton/aol/etc.) and the domain agrees with the company/deal/brand context; do not use POS/vendor/group domains that conflict with the row. " + + "Map that helper column to LEAD_WEBSITE, and keep the original email mapped to CONTACT_EMAIL. " + + "If a company/restaurant row contains structured owners, decision makers, or contact lists, expand those people into separate contact rows that repeat the parent lead identity and map one person to CONTACT_* fields per row. " + + "Multiple rows can share the same LEADBAY_ID/company and import as separate contacts on that lead. " + + "Drop blank-header columns and placeholder values before import. There is no standard LEAD_PHONE field here; preserve establishment/company phone through an intentional custom field.\n\n" + + "Ambiguous resolver rows: try to disambiguate before giving up. Do not manually fill LEADBAY_ID unless the selected candidate uniquely matches exact registry/CRM id, exact phone, exact domain with only one candidate, or name plus a clear same-place address match with postcode/city. Compare addresses intelligently as a human would, recognizing ordinary formatting/abbreviation/spelling differences without reducing the decision to rigid rules. If several candidates share the same website/domain, use location evidence (street, postcode, city/neighborhood), phone, source URL path/location slug, and location words in the source name to pick the specific branch when exactly one candidate matches. Root domain, brand, postcode, or city alone is not enough. Keep still-uncertain LEADBAY_ID values blank so Leadbay can crawl/late-match from LEAD_WEBSITE.\n\n" + + "Notes/context: import only clean scalar CRM fields through this tool. If the source file contains meaningful per-lead notes, data-quality warnings that affect outreach, owner evidence URLs, or client context that should become Leadbay notes, preserve them separately and call leadbay_add_note after the import returns lead IDs. For dry runs, report which notes would be written. Do not turn noisy scraper plumbing or long reasoning blobs into CRM fields.\n\n" + "Custom fields: pass org-defined custom field mappings as 'CUSTOM.' (raw wire format) in " + "`mappings.fields`, OR use the ergonomic `mappings.custom_fields` shorthand: `{ColName: 8}` " + "(numeric id) or `{ColName: 'priority_test'}` (field name). Discover available custom fields " + - "via leadbay_list_mappable_fields.\n\n" + + "via leadbay_list_mappable_fields. If a valuable source-system link such as a HubSpot record URL/id has no " + + "existing field, use leadbay_create_custom_field first. Prefer EXTERNAL_ID with config.url_template and import " + + "the stable source id; reuse existing HubSpot linked-id fields when present, and preserve raw identifiers such as hubspot_id and associated_deal in custom fields when meaningful. Fall back to TEXT for full URLs when no stable id/template can be recovered.\n\n" + "Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the " + "Leadbay account; active billing.", write: true, @@ -1345,8 +1369,12 @@ export const importLeads: Tool = { "DEAL_CRM_ID, CONTACT_FIRST_NAME, CONTACT_LAST_NAME, CONTACT_EMAIL, CONTACT_PHONE_NUMBER, " + "CONTACT_TITLE, CONTACT_LINKEDIN, LEAD_STATUS_DATE, OWNER, SCORE, SIREN) or the wire-format " + "string 'CUSTOM.' for org-defined custom fields. At least one entry must target " + - "LEAD_NAME or LEAD_WEBSITE — the wizard needs that to find leads. Use " + - "leadbay_list_mappable_fields to discover the org's custom fields.", + "LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE — the wizard needs an identity field " + + "to find leads. Use leadbay_resolve_import_rows to prepare LEADBAY_ID values, and " + + "leadbay_list_mappable_fields to discover the org's custom fields. Contact rows should include " + + "both parent lead identity fields and CONTACT_* fields; repeated LEADBAY_ID/company values create " + + "multiple contacts on the same lead. Preserve HubSpot/source links by mapping them to a CUSTOM. " + + "field returned by leadbay_create_custom_field when no suitable custom field already exists.", }, custom_fields: { type: "object", diff --git a/packages/core/src/composite/resolve-import-rows.ts b/packages/core/src/composite/resolve-import-rows.ts new file mode 100644 index 00000000..9bbfe3bf --- /dev/null +++ b/packages/core/src/composite/resolve-import-rows.ts @@ -0,0 +1,474 @@ +import type { LeadbayClient } from "../client.js"; +import type { + CrmFieldMappingValue, + MappingsPayload, + RequestMeta, + ResolvePayload, + ResolveResult, + Tool, +} from "../types.js"; + +interface IdentityMappings { + leadbay_id?: string; + crm_id?: string; + name?: string; + website?: string; + phone?: string; + email?: string; + registry_number?: string; + registry_type?: string; + address?: string; + city?: string; + postcode?: string; + country?: string; + linkedin?: string; + facebook?: string; + instagram?: string; + twitter?: string; + tiktok?: string; +} + +interface ResolveImportRowsParams { + records: Array>; + identity_mappings?: IdentityMappings; + include_candidate_profiles?: boolean; + candidate_profile_limit?: number; + lensId?: number; +} + +interface ResolveImportRowsResult { + rows: Array<{ + index: number; + type: ResolveResult["type"]; + resolver_payload: ResolvePayload; + lead_id?: string; + matched_on?: string[]; + candidates?: Extract["candidates"]; + candidate_profiles?: CandidateProfile[]; + would_help?: Extract["would_help"]; + reason?: string; + import_record: Record; + }>; + records_for_import: Array>; + mappings_for_import: MappingsPayload; + identity_mappings_used: IdentityMappings; + mapping_guidance: string[]; + disambiguation_policy: string[]; + summary: { + total: number; + matched: number; + ambiguous: number; + none: number; + unidentifiable: number; + ready_for_import: boolean; + }; + next_action: string; + region: "us" | "fr" | "custom"; + _meta: RequestMeta; +} + +interface CandidateProfile { + lead_id: string; + name?: string | null; + website?: string | null; + location?: { + full?: string | null; + city?: string | null; + state?: string | null; + country?: string | null; + } | null; + phone_numbers?: unknown[]; + description?: string | null; +} + +const SOCIAL_FIELDS = new Set(["linkedin", "facebook", "instagram", "twitter", "tiktok"]); +const RESOLVER_TARGETS = new Set(["LEADBAY_ID", "CRM_ID", "LEAD_NAME", "LEAD_WEBSITE", "SIREN"]); +const DEFAULT_CANDIDATE_PROFILE_LIMIT = 5; + +function coerceCell(v: unknown): string { + if (v == null) return ""; + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return JSON.stringify(v) ?? ""; +} + +function compactMappings(mappings: IdentityMappings): IdentityMappings { + const out: IdentityMappings = {}; + for (const [k, v] of Object.entries(mappings) as Array<[keyof IdentityMappings, string | undefined]>) { + if (v) out[k] = v; + } + return out; +} + +function payloadForRecord( + row: Record, + mappings: IdentityMappings +): ResolvePayload { + const payload: ResolvePayload = {}; + const socials: NonNullable = {}; + for (const [field, column] of Object.entries(mappings) as Array<[keyof IdentityMappings, string]>) { + const value = (row[column] ?? "").trim(); + if (!value) continue; + if (SOCIAL_FIELDS.has(field)) { + socials[field as keyof typeof socials] = value; + } else { + (payload as any)[field] = value; + } + } + if (Object.keys(socials).length > 0) payload.socials = socials; + return payload; +} + +function identityMappingsForImport( + records: Array>, + identityMappings: IdentityMappings +): MappingsPayload { + const fields: Record = {}; + if (records.some((r) => (r.LEADBAY_ID ?? "").trim() !== "")) { + fields.LEADBAY_ID = "LEADBAY_ID"; + } + const add = (field: keyof IdentityMappings, target: CrmFieldMappingValue) => { + const column = identityMappings[field]; + if (!column || fields[column]) return; + if (!records.some((r) => (r[column] ?? "").trim() !== "")) return; + fields[column] = target; + }; + add("leadbay_id", "LEADBAY_ID"); + add("crm_id", "CRM_ID"); + add("registry_number", "SIREN"); + add("name", "LEAD_NAME"); + add("website", "LEAD_WEBSITE"); + return { fields, statuses: {}, default_status: null }; +} + +function disambiguationPolicy(): string[] { + return [ + "Use `matched` lead_id values directly; the tool already writes those into LEADBAY_ID.", + "For `ambiguous` rows, do not choose a candidate from score alone. Score is a tied evidence-band, not a confidence percentage.", + "For every ambiguous row you resolve, keep a short decision note: selected candidate id, evidence used, conflicting evidence checked, or why LEADBAY_ID stayed blank. Report counts and examples to the user.", + "Try to disambiguate relentlessly before giving up: rerun the row with include_candidate_profiles=true and a larger candidate_profile_limit if candidate facts are truncated, and include every trustworthy source signal available (website, full address, postcode, city, phone, registry/CRM id, source URL path, neighborhood/location words).", + "Compare addresses intelligently as a human would. Recognize ordinary formatting, abbreviation, spelling, punctuation, casing, accent, direction, ordinal, and suite/unit differences without treating address comparison as a rigid rule checklist. A clear same-place street address match is strong evidence.", + "Auto-select an ambiguous candidate when hydrated candidate facts uniquely agree with the source row on strong evidence: exact registry number, exact CRM ID, exact canonical website/domain with only one candidate, exact phone, or name plus clear same-place address match with postcode/city and no conflicting evidence.", + "If several candidates share the same website/domain, do not fail fast. Treat it as a chain/multi-location problem: use source street address, postcode, city/neighborhood, phone, source URL path/location slug, and location words in the source name to pick the specific location when exactly one candidate matches.", + "Postcode/city alone is not enough, and brand/root-domain alone is not enough for multi-location sources. If several candidates remain plausible after checking location/phone/path evidence, leave LEADBAY_ID blank.", + "A domain derived from a contact email is useful only when it is a business domain (not gmail/hotmail/outlook/yahoo/icloud/proton/aol/etc.) and the company/contact context agrees with the candidate. If the domain looks like a POS/vendor/agency/group domain or conflicts with row notes, do not use it for LEADBAY_ID selection.", + "If evidence is name-only, fuzzy-name-only, generic directory website, or multiple candidates remain plausible after exhausting location/phone/path evidence, leave LEADBAY_ID blank and import with website/name so Leadbay can crawl or late-match later.", + "When the user asked for qualification after import, qualify only the lead IDs that the import returns. Late website matches may appear later via leadbay_import_status.", + ]; +} + +function mappingGuidance(): string[] { + return [ + "Treat mappings_for_import as a safe identity starting point, not a complete CRM mapping.", + "Before importing, inspect every user column and sample values, then make a preservation plan: standard field, CONTACT_* field, Leadbay note, custom field, derived helper, or skip with a reason. The model, not this helper, should decide the complete mapping.", + "Default to preserving client-provided business data. For meaningful columns with no standard Leadbay field, call leadbay_list_mappable_fields and create/reuse custom fields instead of silently dropping them. Skip only blank placeholders, duplicate plumbing, raw unparsed blobs after useful values are extracted, or values that would actively harm data quality.", + "Always include LEADBAY_ID when records_for_import contains it; it makes deterministic matches import immediately.", + "Also map the best available source identity columns: website/domain/url -> LEAD_WEBSITE, company/account/restaurant name -> LEAD_NAME, CRM/system id -> CRM_ID, registry/SIREN/SIRET/company number -> SIREN.", + "For contact-only or HubSpot contact exports, derive a separate company_domain/company_website column from CONTACT_EMAIL only when the email domain is a real business domain and agrees with the company/deal/brand context. Do not use POS/vendor/group domains that conflict with the row, and do not derive company identity from private mailbox domains such as gmail.com, hotmail.com, outlook.com, yahoo.com, icloud.com, proton.me/protonmail.com, aol.com, or similar consumer email providers.", + "Map contact-person columns when the file contains people: first_name -> CONTACT_FIRST_NAME, last_name -> CONTACT_LAST_NAME, job_title/title -> CONTACT_TITLE, contact email -> CONTACT_EMAIL, contact phone -> CONTACT_PHONE_NUMBER, contact LinkedIn -> CONTACT_LINKEDIN. If a company/restaurant row contains structured owners, decision makers, or contact lists, expand those people into additional import rows that repeat the parent lead identity and contain one CONTACT_* person per row. Multiple rows may point to the same LEADBAY_ID/company; import them as separate contacts on that lead.", + "Preserve valuable source-system links. For HubSpot URLs, prefer extracting the stable object id into a clean column and mapping it to an existing or newly created EXTERNAL_ID custom field. Reuse an existing HubSpot linked-id field when present. Preserve raw source identifiers such as hubspot_id and associated_deal in custom fields when they are not already represented by a better standard/custom field. Use TEXT only when no stable id/template can be recovered.", + "Clean source-system deal names before using them as LEAD_NAME: strip import campaign suffixes such as BYOC, BYOC only, DD, Uber, trailing separators, and duplicate pipeline labels, while preserving the original associated deal/source value in a custom field when it is meaningful to the user's workflow.", + "Drop blank-header columns and placeholder values like `couldn't find`, `yes`, empty arrays, and raw JSON blobs unless you first extract meaningful scalar fields.", + "Leadbay has CONTACT_PHONE_NUMBER but no standard LEAD_PHONE field in this surface. Preserve establishment/company phone only via an intentional custom field, not by pretending it is a contact phone.", + "Preserve meaningful client notes, data-quality warnings that affect outreach, source record links, and owner/evidence URLs when they help the user's workflow. Do not map noisy scraper plumbing, duplicate blank columns, placeholder values, or long reasoning text.", + "If the file contains meaningful per-lead notes/context, keep that text aside during import and add it to the imported/resolved leads with leadbay_add_note after import when that tool is available. For dry runs, report which notes would be written. If notes cannot be written and the user asked to preserve the text, create/reuse an import-notes custom field.", + "For scraped owner/email JSON columns, extract the best scalar values into new clean columns before import; do not pass raw JSON blobs as core CRM fields.", + "If no confident standard/custom mapping exists for a meaningful user column, create or reuse a custom field unless the column is blank/noisy/duplicate and record why it was skipped.", + ]; +} + +async function hydrateCandidateProfiles( + client: LeadbayClient, + candidates: Extract["candidates"], + lensId: number, + limit: number +): Promise { + const selected = candidates.slice(0, Math.max(0, limit)); + const settled = await Promise.allSettled( + selected.map((c) => + client.request("GET", `/lenses/${lensId}/leads/${c.lead_id}`) + ) + ); + const out: CandidateProfile[] = []; + settled.forEach((r, i) => { + if (r.status !== "fulfilled") return; + const lead = r.value; + out.push({ + lead_id: selected[i].lead_id, + name: lead.name ?? null, + website: lead.website ?? null, + location: lead.location + ? { + full: lead.location.full ?? null, + city: lead.location.city ?? null, + state: lead.location.state ?? null, + country: lead.location.country ?? null, + } + : null, + phone_numbers: Array.isArray(lead.phone_numbers) ? lead.phone_numbers : [], + description: typeof lead.description === "string" ? lead.description.slice(0, 400) : null, + }); + }); + return out; +} + +function hasResolverTarget(mappings: MappingsPayload): boolean { + return Object.values(mappings.fields).some((v) => RESOLVER_TARGETS.has(v)); +} + +export const resolveImportRows: Tool = { + name: "leadbay_resolve_import_rows", + annotations: { + title: "Resolve import row identities", + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + description: + "Resolve messy CSV-shaped lead rows against Leadbay before file import. The tool sends each row's available " + + "identity signals to POST /leads/resolve, returns matched lead IDs or ambiguous candidate IDs, and produces " + + "`records_for_import` plus a SAFE identity-only `mappings_for_import` starting point for " + + "leadbay_import_leads / leadbay_import_and_qualify. This tool deliberately does not try to understand every " + + "CSV dialect; the agent should inspect the file, derive clean helper columns when useful, pass explicit " + + "`identity_mappings`, and build the final CRM mapping from mapping_guidance.\n\n" + + "Use this before importing user-supplied files when domains, names, CRM IDs, registry numbers, or Leadbay IDs " + + "may be inconsistently formatted. For contact-only files, first derive company website/domain from business " + + "contact emails where possible, while ignoring consumer mailbox domains. Deterministic matches get a LEADBAY_ID column inserted so the standard " + + "import commits immediately. Ambiguous rows are deliberately left without LEADBAY_ID; inspect candidates " + + "and choose one only when the evidence is good. Rows with websites but no match can still be imported; " + + "Leadbay may crawl and match them later, and leadbay_import_status can surface late matches.", + write: false, + version: "0.6.4", + inputSchema: { + type: "object", + properties: { + records: { + type: "array", + description: + "CSV-shaped rows from the user file. Values may be strings, numbers, booleans, null, arrays, or objects; non-scalars are JSON-stringified for resolver/import preparation.", + items: { type: "object" }, + }, + identity_mappings: { + type: "object", + description: + "Resolver field -> source column map chosen by the agent after inspecting the file, e.g. {website:'company_domain', name:'Company', crm_id:'Salesforce ID', registry_number:'SIREN'}. May point to clean columns the agent derived before calling this tool. This tool does not infer mappings from header names.", + properties: { + leadbay_id: { type: "string" }, + crm_id: { type: "string" }, + name: { type: "string" }, + website: { type: "string" }, + phone: { type: "string" }, + email: { type: "string" }, + registry_number: { type: "string" }, + registry_type: { type: "string" }, + address: { type: "string" }, + city: { type: "string" }, + postcode: { type: "string" }, + country: { type: "string" }, + linkedin: { type: "string" }, + facebook: { type: "string" }, + instagram: { type: "string" }, + twitter: { type: "string" }, + tiktok: { type: "string" }, + }, + additionalProperties: false, + }, + include_candidate_profiles: { + type: "boolean", + description: + "When true, hydrate ambiguous candidate IDs with lightweight lead facts from the active lens. Use on small batches or rerun on only ambiguous rows; large ambiguous files can return many candidates.", + }, + candidate_profile_limit: { + type: "number", + description: `Maximum candidates to hydrate per ambiguous row when include_candidate_profiles=true (default ${DEFAULT_CANDIDATE_PROFILE_LIMIT}).`, + }, + lensId: { + type: "number", + description: "Lens ID used for candidate profile hydration. Defaults to the user's active lens.", + }, + }, + required: ["records"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + rows: { + type: "array", + description: + "Per-input resolution result. Matched rows include lead_id; ambiguous rows include candidates; none/unidentifiable rows explain what extra signal would help.", + items: { type: "object" }, + }, + records_for_import: { + type: "array", + description: + "Import-ready records. Matched rows include LEADBAY_ID. Ambiguous/unresolved rows preserve user data and rely on website/name/CRM fields for normal or late matching.", + items: { type: "object" }, + }, + mappings_for_import: { + type: "object", + description: + "Safe identity-only mapping starter. The agent should review/extend this using mapping_guidance and leadbay_list_mappable_fields before importing.", + }, + identity_mappings_used: { type: "object" }, + mapping_guidance: { + type: "array", + description: "Instructions for building the final import mappings from the source columns.", + items: { type: "string" }, + }, + disambiguation_policy: { + type: "array", + description: "Rules the agent should follow before writing LEADBAY_ID onto ambiguous rows.", + items: { type: "string" }, + }, + summary: { type: "object" }, + next_action: { type: "string" }, + region: { type: "string" }, + _meta: { type: "object" }, + }, + required: [ + "rows", + "records_for_import", + "mappings_for_import", + "identity_mappings_used", + "mapping_guidance", + "disambiguation_policy", + "summary", + "next_action", + "region", + "_meta", + ], + }, + execute: async ( + client: LeadbayClient, + params: ResolveImportRowsParams + ): Promise => { + if (!Array.isArray(params.records) || params.records.length === 0) { + throw client.makeError( + "RESOLVE_IMPORT_EMPTY_INPUT", + "records[] must contain at least one row", + "Pass the rows from the user file, then import records_for_import.", + "POST /leads/resolve" + ); + } + + const rows = params.records.map((rec, i) => { + if (rec == null || typeof rec !== "object" || Array.isArray(rec)) { + throw client.makeError( + "RESOLVE_IMPORT_INVALID_ROW", + `records[${i}] must be a plain object`, + "Pass each input row as { ColumnName: value, ... }.", + "POST /leads/resolve" + ); + } + const out: Record = {}; + for (const [k, v] of Object.entries(rec)) out[k] = coerceCell(v); + return out; + }); + + const identityMappings = compactMappings(params.identity_mappings ?? {}); + + const allColumns = new Set(rows.flatMap((r) => Object.keys(r))); + for (const [field, column] of Object.entries(identityMappings)) { + if (!allColumns.has(column)) { + throw client.makeError( + "RESOLVE_IMPORT_MAPPING_KEY_UNKNOWN", + `identity_mappings.${field} points to missing column ${JSON.stringify(column)}`, + "Use a source column that exists in at least one input record.", + "POST /leads/resolve" + ); + } + } + + const outputs: ResolveImportRowsResult["rows"] = []; + const recordsForImport: Array> = []; + + const results = await Promise.all( + rows.map(async (row) => { + const payload = payloadForRecord(row, identityMappings); + const result = await client.request("POST", "/leads/resolve", payload); + return { payload, result }; + }) + ); + + let matched = 0; + let ambiguous = 0; + let none = 0; + let unidentifiable = 0; + + const hydrateProfiles = params.include_candidate_profiles === true; + const candidateProfileLimit = + params.candidate_profile_limit ?? DEFAULT_CANDIDATE_PROFILE_LIMIT; + const hydrationLensId = + hydrateProfiles ? params.lensId ?? (await client.resolveDefaultLens()) : null; + + for (let index = 0; index < results.length; index++) { + const { payload, result } = results[index]; + const importRecord = { ...rows[index] }; + const rowOut: ResolveImportRowsResult["rows"][number] = { + index, + type: result.type, + resolver_payload: payload, + import_record: importRecord, + }; + + if (result.type === "matched") { + matched++; + importRecord.LEADBAY_ID = result.lead_id; + rowOut.lead_id = result.lead_id; + rowOut.matched_on = result.matched_on; + } else if (result.type === "ambiguous") { + ambiguous++; + rowOut.candidates = result.candidates; + if (hydrateProfiles && hydrationLensId !== null) { + rowOut.candidate_profiles = await hydrateCandidateProfiles( + client, + result.candidates, + hydrationLensId, + candidateProfileLimit + ); + } + } else if (result.type === "none") { + none++; + rowOut.would_help = result.would_help; + } else { + unidentifiable++; + rowOut.reason = result.reason; + } + + recordsForImport.push(importRecord); + outputs.push(rowOut); + } + + const mappingsForImport = identityMappingsForImport(recordsForImport, identityMappings); + const readyForImport = hasResolverTarget(mappingsForImport); + + return { + rows: outputs, + records_for_import: recordsForImport, + mappings_for_import: mappingsForImport, + identity_mappings_used: identityMappings, + mapping_guidance: mappingGuidance(), + disambiguation_policy: disambiguationPolicy(), + summary: { + total: rows.length, + matched, + ambiguous, + none, + unidentifiable, + ready_for_import: readyForImport, + }, + next_action: readyForImport + ? "Review ambiguous candidates using disambiguation_policy. Build the final mapping from mappings_for_import plus mapping_guidance, then call leadbay_import_leads or leadbay_import_and_qualify with records_for_import and the reviewed mapping." + : "Add or map at least one import resolver column (LEADBAY_ID, CRM_ID, LEAD_NAME, LEAD_WEBSITE, or SIREN), then call leadbay_import_leads.", + region: client.region, + _meta: client.lastMeta ?? { + region: client.region, + endpoint: "POST /leads/resolve", + latency_ms: null, + retry_after: null, + }, + }; + }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a429904..3622227e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,6 +57,7 @@ import { setEpilogueStatus } from "./tools/set-epilogue-status.js"; import { removeEpilogue } from "./tools/remove-epilogue.js"; import { previewBulkEnrichment } from "./tools/preview-bulk-enrichment.js"; import { launchBulkEnrichment } from "./tools/launch-bulk-enrichment.js"; +import { createCustomField } from "./tools/create-custom-field.js"; // ─── Composite workflow tools — agent-facing surface ───────────────────── @@ -70,6 +71,7 @@ import { researchLead } from "./composite/research-lead.js"; import { recallOrderedTitles } from "./composite/recall-ordered-titles.js"; import { accountStatus } from "./composite/account-status.js"; import { bulkQualifyLeads } from "./composite/bulk-qualify-leads.js"; +import { resolveImportRows } from "./composite/resolve-import-rows.js"; import { importLeads } from "./composite/import-leads.js"; import { importAndQualify } from "./composite/import-and-qualify.js"; import { importStatus } from "./composite/import-status.js"; @@ -114,11 +116,12 @@ export { updateLens, updateLensFilter, createLensDraft, promoteLens, setUserPrompt, clearUserPrompt, pickClarification, dismissClarification, setEpilogueStatus, removeEpilogue, previewBulkEnrichment, launchBulkEnrichment, + createCustomField, // existing composite researchCompany, prepareOutreach, // new composite reads pullLeads, researchLead, recallOrderedTitles, accountStatus, - bulkEnrichStatus, qualifyStatus, importStatus, + bulkEnrichStatus, qualifyStatus, importStatus, resolveImportRows, // new composite writes bulkQualifyLeads, enrichTitles, adjustAudience, refinePrompt, answerClarification, reportOutreach, importLeads, importAndQualify, @@ -172,6 +175,7 @@ export const granularWriteTools: Tool[] = [ removeEpilogue, previewBulkEnrichment, launchBulkEnrichment, + createCustomField, ]; // Backward-compat alias (existing consumers use granularTools): @@ -194,6 +198,7 @@ export const compositeReadTools: Tool[] = [ bulkEnrichStatus, qualifyStatus, importStatus, + resolveImportRows, // listMappableFields is granular-shaped but the import composites depend on // it for discoverability; expose it always-on so agents can find custom fields // without needing LEADBAY_MCP_ADVANCED=1. @@ -214,6 +219,12 @@ export const compositeWriteTools: Tool[] = [ reportOutreach, importLeads, importAndQualify, + // createCustomField is granular-shaped but file-import prompts depend on it + // to preserve source-system links without requiring advanced-tool exposure. + createCustomField, + // addNote is granular-shaped but file-import prompts depend on it to preserve + // meaningful source-file notes after imports return lead ids. + addNote, ]; // Backward-compat alias for existing consumers. diff --git a/packages/core/src/tools/add-note.ts b/packages/core/src/tools/add-note.ts index 59293233..32c3343f 100644 --- a/packages/core/src/tools/add-note.ts +++ b/packages/core/src/tools/add-note.ts @@ -18,7 +18,8 @@ export const addNote: Tool = { }, description: "Add a note to a lead. Notes are visible to the whole organization in Leadbay. " + - "When to use: low-level — for free-form notes not tied to outreach actions. " + + "When to use: low-level — for free-form notes not tied to outreach actions, including meaningful " + + "per-lead notes/context preserved from an imported file after the import returns lead IDs. " + "When NOT to use: to log an outreach action — use leadbay_report_outreach, which requires verification " + "(gmail/calendar/user_confirmed) to prevent hallucinated outreach poisoning the SDR pipeline.", optional: true, diff --git a/packages/core/src/tools/create-custom-field.ts b/packages/core/src/tools/create-custom-field.ts new file mode 100644 index 00000000..1c56573a --- /dev/null +++ b/packages/core/src/tools/create-custom-field.ts @@ -0,0 +1,146 @@ +import type { LeadbayClient } from "../client.js"; +import type { + CustomCrmFieldConfig, + CustomCrmFieldKind, + CustomFieldDef, + Tool, +} from "../types.js"; + +interface CreateCustomFieldParams { + name: string; + type?: CustomCrmFieldKind; + config?: CustomCrmFieldConfig | null; + if_not_exists?: boolean; +} + +export const createCustomField: Tool = { + name: "leadbay_create_custom_field", + annotations: { + title: "Create CRM custom field", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + description: + "Create an org-level CRM custom field for imports, then use the returned `mapping_value` in " + + "leadbay_import_leads / leadbay_import_and_qualify mappings. Use when the user's file contains valuable " + + "columns that do not fit Leadbay's standard fields, such as source-system deep links, source record IDs, " + + "campaign provenance, or user-requested enrichment attributes.\n\n" + + "For HubSpot record links, prefer `type:'EXTERNAL_ID'` with `config.url_template` and import only the stable " + + "HubSpot id as the CSV value. Example: create field name 'HubSpot Contact', type 'EXTERNAL_ID', " + + "config {url_template:'https://app.hubspot.com/contacts//record/0-1/{value}'}; then map " + + "`hubspot_id` to the returned `mapping_value`. If only a full URL column exists and the id cannot be safely " + + "extracted, use a TEXT field instead.\n\n" + + "When to use: after leadbay_list_mappable_fields shows no suitable existing custom field and preserving the " + + "column matters to the user's goal. When NOT to use: for standard company/contact data that maps to " + + "LEAD_WEBSITE, LEAD_NAME, CONTACT_EMAIL, etc.; do not create custom fields for noisy scraper notes unless " + + "the user explicitly asks to preserve them.", + write: true, + version: "0.6.4", + inputSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "User-visible custom field name, e.g. 'HubSpot Contact'.", + }, + type: { + type: "string", + description: + "Custom field type: TEXT, NUMBER, PRICE, DATE, DATETIME, or EXTERNAL_ID. Defaults to TEXT.", + }, + config: { + type: ["object", "null"], + description: + "Type-specific config. EXTERNAL_ID requires {url_template:'https://.../{value}'}; PRICE requires {currency:'USD'}; DATE/DATETIME may set {format}.", + }, + if_not_exists: { + type: "boolean", + description: + "Default true. If a custom field with the same name already exists, return it instead of creating a duplicate.", + }, + }, + required: ["name"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + type: { type: "string" }, + config: { type: ["object", "null"] }, + mapping_value: { + type: "string", + description: "Wire mapping value to use in import mappings, e.g. CUSTOM.123.", + }, + existed: { + type: "boolean", + description: "True when if_not_exists reused an existing custom field.", + }, + }, + required: ["id", "name", "type", "mapping_value", "existed"], + }, + execute: async ( + client: LeadbayClient, + params: CreateCustomFieldParams + ) => { + const name = params.name?.trim(); + if (!name) { + throw client.makeError( + "CUSTOM_FIELD_NAME_REQUIRED", + "name must be a non-empty string", + "Pass a user-visible custom field name, e.g. 'HubSpot Contact'.", + "POST /crm/custom_fields" + ); + } + + const type = params.type ?? "TEXT"; + const config = params.config ?? null; + + if (type === "EXTERNAL_ID") { + const urlTemplate = config?.url_template ?? config?.urlTemplate; + if (!urlTemplate || !urlTemplate.includes("{value}")) { + throw client.makeError( + "CUSTOM_FIELD_EXTERNAL_ID_TEMPLATE_REQUIRED", + "EXTERNAL_ID custom fields require config.url_template containing {value}", + "Use a URL template like https://app.hubspot.com/contacts//record/0-1/{value}.", + "POST /crm/custom_fields" + ); + } + } + + if (params.if_not_exists ?? true) { + const existing = await client.request( + "GET", + "/crm/custom_fields" + ); + const found = (existing ?? []).find((f) => f.name.toLowerCase() === name.toLowerCase()); + if (found) { + return { + ...found, + mapping_value: `CUSTOM.${found.id}`, + existed: true, + }; + } + } + + const body = { + name, + type, + ...(config ? { config } : {}), + }; + const created = await client.request( + "POST", + "/crm/custom_fields", + body + ); + + return { + ...created, + mapping_value: `CUSTOM.${created.id}`, + existed: false, + }; + }, +}; diff --git a/packages/core/src/tools/list-mappable-fields.ts b/packages/core/src/tools/list-mappable-fields.ts index 41574768..e8dfc8cb 100644 --- a/packages/core/src/tools/list-mappable-fields.ts +++ b/packages/core/src/tools/list-mappable-fields.ts @@ -61,7 +61,7 @@ const STANDARD_FIELDS: ReadonlyArray<{ }> = [ { name: "LEAD_NAME", description: "Company name. Required for fuzzy match." }, { name: "LEAD_WEBSITE", description: "Company domain (preferred matcher; protocol/path auto-stripped)." }, - { name: "EMAIL", description: "Email — domain part used as a website-fallback matcher." }, + { name: "EMAIL", description: "Lead/company email — domain part may be used as a website-fallback matcher. For a person's email, use CONTACT_EMAIL and optionally derive a separate business-domain column for LEAD_WEBSITE." }, { name: "CRM_ID", description: "Your CRM's stable lead identifier (round-trips back as crm_id on the lead)." }, { name: "LEADBAY_ID", description: "Leadbay UUID, if you already have one (matches by id, no fuzzy needed)." }, { name: "DEAL_CRM_ID", description: "Your CRM's deal id (one deal per row; combined with LEAD_STATUS forms a sales record)." }, @@ -79,7 +79,7 @@ const STANDARD_FIELDS: ReadonlyArray<{ { name: "SIREN", description: "French SIREN registry number (9 digits) — auto-matches against the FR registry." }, { name: "CONTACT_FIRST_NAME", description: "Contact first name (creates an org contact)." }, { name: "CONTACT_LAST_NAME", description: "Contact last name." }, - { name: "CONTACT_EMAIL", description: "Contact email." }, + { name: "CONTACT_EMAIL", description: "Contact email. Does not replace the parent company's LEAD_WEBSITE; derive a company domain from this only when it is a business domain, not a personal mailbox provider." }, { name: "CONTACT_PHONE_NUMBER", description: "Contact phone (free-form)." }, { name: "CONTACT_TITLE", description: "Contact job title." }, { name: "CONTACT_LINKEDIN", description: "Contact LinkedIn URL." }, @@ -113,7 +113,7 @@ function describeCustomField(f: CustomFieldDef): string { }.`; case "EXTERNAL_ID": return `Custom EXTERNAL_ID field — opaque id${ - f.config?.urlTemplate ? ` (deep-link template configured)` : "" + f.config?.url_template || f.config?.urlTemplate ? ` (deep-link template configured)` : "" }.`; default: // Unknown kind — surface plainly without rejecting. @@ -142,6 +142,13 @@ export const listMappableFields: Tool< "LEAD_STATUS, contact + location + sector fields) and `custom_fields` (this org's user-defined fields — id, " + "name, type, and the literal `mapping_value` you pass in `mappings.fields`). For custom fields, `mapping_value` " + "is the wire-format string `CUSTOM.` — pass it verbatim.\n\n" + + "For contact exports, map person data to CONTACT_* fields and still provide parent-company identity via " + + "LEADBAY_ID/LEAD_WEBSITE/LEAD_NAME/CRM_ID/SIREN. When contact emails contain business domains, agents may " + + "derive a clean company-domain column for LEAD_WEBSITE only when the domain agrees with the row's company/deal/brand context, while preserving the original email as CONTACT_EMAIL. " + + "For import files, audit every meaningful source column. If no standard/contact field fits, preserve the data by creating or reusing a custom field unless the column is blank, duplicate plumbing, raw unparsed noise after useful extraction, or harmful to data quality. " + + "For HubSpot or other source-system deep links, create or reuse an EXTERNAL_ID/TEXT custom field with " + + "leadbay_create_custom_field, then map the source id/link to the returned mapping_value. Backend mapping_hints " + + "are advisory only; for contact files, do not accept hints such as first_name -> LEAD_NAME when the column is clearly a person field.\n\n" + "Optional `for_records` param: pass a sample of CSV-shaped rows and the tool also runs the wizard's preprocess " + "on them, attaching `mapping_hints` (per-column AI-confidence suggestions) and `custom_field_candidates` " + "(custom fields that match unmapped columns by exact / case-insensitive / fuzzy name) to the response. " + @@ -242,7 +249,10 @@ export const listMappableFields: Tool< }; if (Array.isArray(params.for_records) && params.for_records.length > 0) { - const notes: string[] = []; + const notes: string[] = [ + "mapping_hints are backend suggestions, not final truth. Inspect values semantically before importing; person columns like first_name/last_name should map to CONTACT_* fields, not LEAD_NAME.", + "If mapping_hints disagree with the user's file semantics, ignore the hint. Use leadbay_resolve_import_rows with explicit identity_mappings for identity matching, then author final mappings yourself.", + ]; try { const sample = params.for_records.slice(0, PREVIEW_SAMPLE_CAP); const headerSet = new Set(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3abd0993..d87fa1a5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -490,6 +490,7 @@ export type CustomCrmFieldKind = export interface CustomCrmFieldConfig { currency?: string; format?: string | null; + url_template?: string; urlTemplate?: string; } @@ -538,6 +539,47 @@ export interface ImportLeadsResponse { lead_ids: string[]; } +// ─── Lead resolver payloads (POST /1.5/leads/resolve) ───────────────────── + +export interface ResolveSocialsPayload { + linkedin?: string | null; + facebook?: string | null; + instagram?: string | null; + twitter?: string | null; + tiktok?: string | null; +} + +export interface ResolvePayload { + leadbay_id?: string | null; + crm_id?: string | null; + name?: string | null; + website?: string | null; + phone?: string | null; + email?: string | null; + registry_number?: string | null; + registry_type?: string | null; + address?: string | null; + city?: string | null; + postcode?: string | null; + country?: string | null; + socials?: ResolveSocialsPayload | null; +} + +export interface ResolveCandidate { + lead_id: string; + score: number; + matched_on: string[]; + lead_fields_populated: Array< + "registry" | "website" | "phone" | "email" | "address" | "linkedin" | (string & {}) + >; +} + +export type ResolveResult = + | { type: "matched"; lead_id: string; matched_on: string[] } + | { type: "ambiguous"; candidates: ResolveCandidate[] } + | { type: "none"; would_help: Array<"website" | "registry_number" | (string & {})> } + | { type: "unidentifiable"; reason: string }; + // One entry in record.records[] — { column_name, value, field? }. export interface ImportRecordCell { column_name: string; diff --git a/packages/core/test/unit/composite/import-leads.test.ts b/packages/core/test/unit/composite/import-leads.test.ts index 4ad60169..ce144fd9 100644 --- a/packages/core/test/unit/composite/import-leads.test.ts +++ b/packages/core/test/unit/composite/import-leads.test.ts @@ -647,6 +647,34 @@ describe("leadbay_import_leads — records mode", () => { }); }); + it("accepts LEADBAY_ID-only mappings as deterministic resolver input", async () => { + mockHttp([ + { method: "GET", path: "/1.5/users/me", status: 200, body: adminMe() }, + { + method: "POST", + path: /\/1\.5\/imports\?file_name=/, + status: 200, + body: makeImportPayload({ preFinished: true }), + }, + { + method: "GET", + path: /\/1\.5\/imports\/[a-z0-9-]+$/, + status: 200, + body: makeImportPayload({ preFinished: true }), + }, + ]); + + const out = await importLeads.execute(newClient(), { + records: [{ LEADBAY_ID: "lead-apple" }], + mappings: { fields: { LEADBAY_ID: "LEADBAY_ID" } }, + dry_run: true, + }); + + expect(out.dry_run).toBe(true); + const uploadReq = getHttpRequests().find((r) => /\/imports\?file_name=/.test(r.path)); + expect(uploadReq?.body).toContain("MCP_ROW_ID,LEADBAY_ID"); + }); + it("dry_run: no update_mappings; not_imported uses rowId; domain only when LEAD_WEBSITE parsed", async () => { mockHttp([ { method: "GET", path: "/1.5/users/me", status: 200, body: adminMe() }, diff --git a/packages/core/test/unit/composite/resolve-import-rows.test.ts b/packages/core/test/unit/composite/resolve-import-rows.test.ts new file mode 100644 index 00000000..fe538cdf --- /dev/null +++ b/packages/core/test/unit/composite/resolve-import-rows.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +import { vi } from "vitest"; +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { resolveImportRows } from "../../../src/composite/resolve-import-rows.js"; + +const BASE = "https://api-us.leadbay.app"; + +function newClient() { + return new LeadbayClient(BASE, "u.test-token", "us"); +} + +beforeEach(() => { + resetHttpMock(); +}); + +describe("leadbay_resolve_import_rows", () => { + it("resolves messy rows and emits import-ready records/mappings", async () => { + mockHttp([ + { + method: "POST", + path: "/1.5/leads/resolve", + status: 200, + body: { + type: "matched", + lead_id: "lead-apple", + matched_on: ["website_exact"], + }, + }, + { + method: "POST", + path: "/1.5/leads/resolve", + status: 200, + body: { + type: "ambiguous", + candidates: [ + { + lead_id: "lead-acme-a", + score: 90, + matched_on: ["name_exact"], + lead_fields_populated: ["website"], + }, + { + lead_id: "lead-acme-b", + score: 90, + matched_on: ["name_exact"], + lead_fields_populated: ["address"], + }, + ], + }, + }, + { + method: "POST", + path: "/1.5/leads/resolve", + status: 200, + body: { type: "none", would_help: ["website", "registry_number"] }, + }, + ]); + + const out = await resolveImportRows.execute(newClient(), { + records: [ + { Company: "Apple", Domain: "https://apple.com" }, + { Company: "Acme", City: "Paris" }, + { Company: "Unknown Co" }, + ], + identity_mappings: { name: "Company", website: "Domain", city: "City" }, + }); + + expect(out.summary).toMatchObject({ + total: 3, + matched: 1, + ambiguous: 1, + none: 1, + unidentifiable: 0, + ready_for_import: true, + }); + expect(out.identity_mappings_used).toMatchObject({ + name: "Company", + website: "Domain", + city: "City", + }); + expect(out.rows[0]).toMatchObject({ + index: 0, + type: "matched", + lead_id: "lead-apple", + matched_on: ["website_exact"], + }); + expect(out.rows[1]).toMatchObject({ + index: 1, + type: "ambiguous", + candidates: [{ lead_id: "lead-acme-a" }, { lead_id: "lead-acme-b" }], + }); + expect(out.records_for_import[0]).toMatchObject({ + Company: "Apple", + Domain: "https://apple.com", + LEADBAY_ID: "lead-apple", + }); + expect(out.records_for_import[1].LEADBAY_ID).toBeUndefined(); + expect(out.mappings_for_import.fields).toMatchObject({ + LEADBAY_ID: "LEADBAY_ID", + Company: "LEAD_NAME", + Domain: "LEAD_WEBSITE", + }); + expect(out.mapping_guidance.join("\n")).toContain("contact-person columns"); + expect(out.disambiguation_policy.join("\n")).toContain("do not choose a candidate from score alone"); + + const requests = getHttpRequests(); + expect(JSON.parse(requests[0].body ?? "{}")).toEqual({ + name: "Apple", + website: "https://apple.com", + }); + expect(JSON.parse(requests[1].body ?? "{}")).toEqual({ + name: "Acme", + city: "Paris", + }); + }); + + it("can hydrate ambiguous candidates with lightweight active-lens profiles", async () => { + mockHttp([ + { + method: "POST", + path: "/1.5/leads/resolve", + status: 200, + body: { + type: "ambiguous", + candidates: [ + { + lead_id: "lead-a", + score: 12, + matched_on: ["name_exact"], + lead_fields_populated: ["address"], + }, + { + lead_id: "lead-b", + score: 12, + matched_on: ["name_exact"], + lead_fields_populated: ["website"], + }, + ], + }, + }, + { + method: "GET", + path: "/1.5/users/me", + status: 200, + body: { + id: "u-1", + organization: { id: "org-1", name: "Org" }, + last_requested_lens: 42, + }, + }, + { + method: "GET", + path: "/1.5/lenses/42/leads/lead-a", + status: 200, + body: { + id: "lead-a", + name: "Dhaba", + website: null, + location: { full: "Queens, NY", city: "New York", country: "US" }, + phone_numbers: [], + description: "Queens location", + }, + }, + { + method: "GET", + path: "/1.5/lenses/42/leads/lead-b", + status: 200, + body: { + id: "lead-b", + name: "Dhaba", + website: "dhabanyc.com", + location: { full: "108 Lexington Ave, New York, NY", city: "New York", country: "US" }, + phone_numbers: [], + description: "Lexington Ave location", + }, + }, + ]); + + const out = await resolveImportRows.execute(newClient(), { + records: [{ Company: "Dhaba", Domain: "dhabanyc.com" }], + identity_mappings: { name: "Company", website: "Domain" }, + include_candidate_profiles: true, + }); + + expect(out.rows[0]).toMatchObject({ + type: "ambiguous", + candidate_profiles: [ + { lead_id: "lead-a", location: { full: "Queens, NY" } }, + { lead_id: "lead-b", website: "dhabanyc.com" }, + ], + }); + }); + + it("honors explicit identity mappings and rejects missing columns", async () => { + await expect( + resolveImportRows.execute(newClient(), { + records: [{ "Account Name": "Stripe" }], + identity_mappings: { website: "Website" }, + }) + ).rejects.toMatchObject({ + error: true, + code: "RESOLVE_IMPORT_MAPPING_KEY_UNKNOWN", + }); + expect(getHttpRequests()).toEqual([]); + }); + + it("supports unidentifiable rows without an import resolver mapping", async () => { + mockHttp([ + { + method: "POST", + path: "/1.5/leads/resolve", + status: 200, + body: { type: "unidentifiable", reason: "no identifying fields supplied" }, + }, + ]); + + const out = await resolveImportRows.execute(newClient(), { + records: [{ Notes: "met at booth 12" }], + }); + + expect(out.summary).toMatchObject({ + total: 1, + matched: 0, + unidentifiable: 1, + ready_for_import: false, + }); + expect(out.rows[0]).toMatchObject({ + type: "unidentifiable", + reason: "no identifying fields supplied", + resolver_payload: {}, + }); + expect(out.mappings_for_import.fields).toEqual({}); + }); +}); diff --git a/packages/core/test/unit/tools/create-custom-field.test.ts b/packages/core/test/unit/tools/create-custom-field.test.ts new file mode 100644 index 00000000..c1624471 --- /dev/null +++ b/packages/core/test/unit/tools/create-custom-field.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + mockHttp, + resetHttpMock, + httpsMockFactory, + getHttpRequests, +} from "../../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "../../../src/client.js"; +import { createCustomField } from "../../../src/tools/create-custom-field.js"; + +const BASE = "https://api-us.leadbay.app"; + +function newClient() { + return new LeadbayClient(BASE, "u.test-token", "us"); +} + +beforeEach(() => { + resetHttpMock(); +}); + +describe("leadbay_create_custom_field", () => { + it("creates an EXTERNAL_ID field and returns the import mapping value", async () => { + mockHttp([ + { method: "GET", path: "/1.5/crm/custom_fields", status: 200, body: [] }, + { + method: "POST", + path: "/1.5/crm/custom_fields", + status: 200, + body: { + id: "8", + name: "HubSpot Contact", + type: "EXTERNAL_ID", + config: { + url_template: "https://app.hubspot.com/contacts/123/record/0-1/{value}", + }, + }, + }, + ]); + + const out = await createCustomField.execute(newClient(), { + name: "HubSpot Contact", + type: "EXTERNAL_ID", + config: { + url_template: "https://app.hubspot.com/contacts/123/record/0-1/{value}", + }, + }); + + expect(out).toMatchObject({ + id: "8", + name: "HubSpot Contact", + type: "EXTERNAL_ID", + mapping_value: "CUSTOM.8", + existed: false, + }); + const requests = getHttpRequests(); + expect(requests.map((r) => `${r.method} ${r.path}`)).toEqual([ + "GET /1.5/crm/custom_fields", + "POST /1.5/crm/custom_fields", + ]); + expect(JSON.parse(requests[1].body ?? "{}")).toEqual({ + name: "HubSpot Contact", + type: "EXTERNAL_ID", + config: { + url_template: "https://app.hubspot.com/contacts/123/record/0-1/{value}", + }, + }); + }); + + it("reuses an existing same-name field by default", async () => { + mockHttp([ + { + method: "GET", + path: "/1.5/crm/custom_fields", + status: 200, + body: [ + { + id: "9", + name: "HubSpot Contact", + type: "EXTERNAL_ID", + config: { + url_template: "https://app.hubspot.com/contacts/123/record/0-1/{value}", + }, + }, + ], + }, + ]); + + const out = await createCustomField.execute(newClient(), { + name: "hubspot contact", + type: "EXTERNAL_ID", + config: { + url_template: "https://app.hubspot.com/contacts/123/record/0-1/{value}", + }, + }); + + expect(out).toMatchObject({ + id: "9", + name: "HubSpot Contact", + mapping_value: "CUSTOM.9", + existed: true, + }); + expect(getHttpRequests().map((r) => `${r.method} ${r.path}`)).toEqual([ + "GET /1.5/crm/custom_fields", + ]); + }); + + it("rejects EXTERNAL_ID without a url_template containing {value}", async () => { + await expect( + createCustomField.execute(newClient(), { + name: "HubSpot Contact", + type: "EXTERNAL_ID", + config: { url_template: "https://app.hubspot.com/contacts/123" }, + }) + ).rejects.toMatchObject({ + error: true, + code: "CUSTOM_FIELD_EXTERNAL_ID_TEMPLATE_REQUIRED", + }); + expect(getHttpRequests()).toEqual([]); + }); +}); diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 8d579529..7be14f4c 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — @leadbay/mcp +## 0.6.4 — UNRELEASED + +**Agentic file import prep**: adds `leadbay_resolve_import_rows`, a read-only resolver that calls the new backend `/leads/resolve` endpoint for messy CSV-shaped rows, returns matched/ambiguous/unresolved candidates, can hydrate ambiguous candidates with active-lens profile facts, and emits `records_for_import` plus safe identity-only `mappings_for_import` for `leadbay_import_leads` / `leadbay_import_and_qualify`. Import mappings now accept `LEADBAY_ID`, `CRM_ID`, and `SIREN` as resolver fields, so rows with resolved Leadbay IDs can import immediately while website-only rows can still rely on normal or late matching. New `leadbay_import_file` prompt teaches agents the full inspect → map → resolve → disambiguate → import/qualify workflow. + ## 0.6.3 — 2026-05-12 **Async import schema fix**: `leadbay_import_leads` now declares both its legacy blocking result shape and its async kickoff shape (`{status: "running", handle_id, importIds, progress}`) in `outputSchema`, so Claude Desktop and other MCP SDK clients accept the fast handle response instead of rejecting `structuredContent`. diff --git a/packages/mcp/src/prompts.ts b/packages/mcp/src/prompts.ts index 301eab1d..8666bc55 100644 --- a/packages/mcp/src/prompts.ts +++ b/packages/mcp/src/prompts.ts @@ -73,6 +73,43 @@ const CATALOG: CatalogEntry[] = [ ), ], }, + { + name: "leadbay_import_file", + description: + "Import a user-supplied CSV/file into Leadbay, resolve ambiguous rows, commit the reviewed mapping, and optionally qualify the imported leads.", + arguments: [ + { + name: "file", + description: + "Path or user-visible name of the CSV/file to import. If omitted, use the file the user attached or referenced.", + required: false, + }, + { + name: "instruction", + description: + "Additional user goal, e.g. 'then qualify the leads', 'preserve owner phone as a custom field', or 'only import restaurants in Manhattan'.", + required: false, + }, + ], + render: (args) => [ + userMessage( + `Import the user's Leadbay file${args.file ? ` (${args.file})` : ""} and satisfy this instruction: ${args.instruction ?? "import the rows, resolve identities, and qualify leads if the user asked for qualification"}.\n\n` + + "Workflow:\n" + + "1. Read the file yourself. Inspect every header, sample values from multiple rows, row count, duplicate/blank columns, and obvious dirty data. Build a column preservation plan before importing: for each meaningful column decide standard field, CONTACT_* field, Leadbay note, custom field, derived helper, or skip with a reason. Default to preserving client-provided business data; skip only blank placeholders, duplicate plumbing, raw unparsed blobs after extracting their useful values, or values that would actively harm data quality.\n" + + "2. Build semantic helper columns before resolving. If there is no company website but a contact email uses a real business domain, derive a company_domain/company_website column from it only when that domain agrees with the company/deal/brand context. Ignore consumer mailbox domains such as gmail.com, hotmail.com, outlook.com, yahoo.com, icloud.com, proton.me/protonmail.com, aol.com, live.com, msn.com, me.com, gmx.*, and similar personal email providers. Also ignore POS/vendor/group domains that conflict with the company. Keep the original email for CONTACT_EMAIL.\n" + + "3. Decide resolver identity_mappings from the actual file semantics. Prefer: website/domain/url or vetted derived business email domain -> website; cleaned company/account/restaurant/establishment name -> name; CRM/system id -> crm_id; registry/SIREN/SIRET/company number -> registry_number; full address/city/postcode/country/phone/email/socials when present. For HubSpot/deal exports, clean campaign suffixes like BYOC, BYOC only, DD, Uber, trailing separators, and duplicate pipeline labels before using the value as LEAD_NAME. If a column is ambiguous, inspect row values before mapping it. Do not rely on fixed header names.\n" + + "4. Call leadbay_resolve_import_rows with representative or all rows and your explicit identity_mappings. For large files, batch rows so responses stay readable. Use include_candidate_profiles=true for small batches or rerun it on ambiguous rows only. If a row is ambiguous and candidate profiles are missing or truncated, rerun just those rows with include_candidate_profiles=true and a larger candidate_profile_limit before deciding.\n" + + "5. Disambiguate relentlessly and keep a decision log. Use matched lead_id values directly. For ambiguous candidates, first make sure you have enough evidence: rerun the ambiguous rows with include_candidate_profiles=true and a larger candidate_profile_limit if profiles are truncated, and include every trustworthy source signal available (website, full address, postcode, city, phone, registry/CRM id, source URL path, neighborhood/location words). Compare addresses intelligently as a human would: recognize ordinary formatting, abbreviation, spelling, punctuation, casing, direction, ordinal, and suite/unit differences without reducing the decision to rigid rules. Write LEADBAY_ID when candidate facts uniquely agree with strong source evidence: exact registry/CRM id, exact phone, exact canonical website/domain with only one candidate, or name plus clear same-place address match with postcode/city and no conflict. If several candidates share the same website/domain, treat it as a chain/multi-location problem and use street address, postcode, city/neighborhood, phone, source URL path/location slug, and location words in the source name to pick the specific place when exactly one candidate matches. Never choose from score alone, name-only, fuzzy-name-only, generic directory websites, root-domain-only, brand-only, postcode-only, or city-only evidence. Leave LEADBAY_ID blank only after those checks still leave real ambiguity, and record why.\n" + + "6. Build a clean records array for import from the preservation plan. Preserve user-requested and semantically meaningful business fields, add LEADBAY_ID where resolved, normalize obvious scalar fields, and split JSON/list blobs into useful scalar columns when they contain real business data. For meaningful columns with no standard Leadbay field, call leadbay_list_mappable_fields and create/reuse custom fields rather than dropping the data. Drop blank-header columns and placeholder values like `couldn't find`, `yes`, empty arrays, and raw JSON after useful values have been extracted. Do not preserve scraper plumbing, duplicate blank columns, or long reasoning text, but do preserve meaningful client notes, data-quality warnings that affect outreach, source record links, and evidence URLs when they help the user's workflow.\n" + + "7. Treat contact exports and embedded owner/contact data as lead+contact imports. Map the parent company identity columns (LEADBAY_ID/LEAD_WEBSITE/LEAD_NAME/CRM_ID/SIREN) and also map person columns to CONTACT_FIRST_NAME, CONTACT_LAST_NAME, CONTACT_EMAIL, CONTACT_PHONE_NUMBER, CONTACT_TITLE, CONTACT_LINKEDIN. If a restaurant/company row contains structured owners, decision makers, or contact lists, expand those people into additional import rows that repeat the parent lead identity and contain one CONTACT_* person per row. Multiple rows may share the same LEADBAY_ID/company; import each row as a contact for that lead.\n" + + "8. Preserve valuable HubSpot record links and source evidence. If HubSpot URL/id or source URL columns exist, call leadbay_list_mappable_fields. If no suitable field exists, call leadbay_create_custom_field. Prefer EXTERNAL_ID with config.url_template like https://app.hubspot.com/contacts//record/0-1/{value} and import the stable object id; if an existing HubSpot linked-id field exists, reuse it for the HubSpot URL/id. Preserve raw source identifiers such as hubspot_id and associated_deal in custom fields when they are not already represented by a better standard/custom field. If only a full URL exists and no stable id/template can be recovered, create/use a TEXT custom field for the URL. Leadbay has CONTACT_PHONE_NUMBER but no standard LEAD_PHONE in this tool surface; preserve establishment/company phone only via an intentional custom field.\n" + + "9. Preserve notes intentionally. If the file contains meaningful per-lead notes/context that should live as Leadbay notes, keep them aside during import and, after the import returns lead IDs, call leadbay_add_note for the relevant imported/resolved leads when that tool is available. For dry runs, report which notes would be written. If lead notes are not available and the user asked to preserve the text, create/reuse an import-notes custom field instead of dropping it.\n" + + "10. Build the final mappings yourself. Start from leadbay_resolve_import_rows.mappings_for_import, then map semantically: LEADBAY_ID, LEAD_WEBSITE, LEAD_NAME, CRM_ID, SIREN, LEAD_LOCATION*, LEAD_SECTOR, LEAD_SIZE, contact fields, and useful CUSTOM. fields. Call leadbay_list_mappable_fields before using custom fields.\n" + + "11. Prefer leadbay_import_and_qualify when the user asks to qualify/research after import; otherwise use leadbay_import_leads. For large files or short client timeouts, pass wait_for_completion=false and poll leadbay_import_status. After import, qualify only lead IDs returned by the import; late website matches may appear later via import_status.\n" + + "12. Report counts clearly: rows read, rows skipped, deterministic matches, ambiguous left unresolved, contacts imported, notes written or staged, custom fields created/reused, import IDs/handle IDs, leads imported now, and what will need later polling." + ), + ], + }, { name: "leadbay_refine_audience", description: diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 983561a2..13dfdca0 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -107,6 +107,7 @@ function buildSlashCommandsParagraph(has: (name: string) => boolean): string { commands.push("`/leadbay daily-check-in` (chains account_status → pull_leads → research_lead on the top hit)"); if (has("leadbay_import_and_qualify")) { commands.push("`/leadbay research-a-domain {domain}` (import_and_qualify → research_lead)"); + commands.push("`/leadbay import-file {file}` (resolve_import_rows → import_leads/import_and_qualify → status)"); } if (has("leadbay_refine_prompt")) { commands.push("`/leadbay refine-audience {instruction}` (refine_prompt with clarification handling)"); diff --git a/packages/mcp/test/output-schema-conformance.test.ts b/packages/mcp/test/output-schema-conformance.test.ts index 301ab517..516ed65f 100644 --- a/packages/mcp/test/output-schema-conformance.test.ts +++ b/packages/mcp/test/output-schema-conformance.test.ts @@ -161,6 +161,27 @@ interface ConformanceCase { } const CASES: ConformanceCase[] = [ + { + toolName: "leadbay_resolve_import_rows", + arguments: { + records: [{ Company: "Apple", Domain: "apple.com" }], + identity_mappings: { name: "Company", website: "Domain" }, + }, + setupMocks: () => { + mockHttp([ + { + method: "POST", + path: "/1.5/leads/resolve", + status: 200, + body: { + type: "matched", + lead_id: "lead-apple", + matched_on: ["website_exact"], + }, + }, + ]); + }, + }, { toolName: "leadbay_account_status", arguments: {}, @@ -435,6 +456,34 @@ const CASES: ConformanceCase[] = [ ]); }, }, + { + toolName: "leadbay_create_custom_field", + arguments: { + name: "HubSpot Contact", + type: "EXTERNAL_ID", + config: { + url_template: "https://app.hubspot.com/contacts/123/record/0-1/{value}", + }, + }, + setupMocks: () => { + mockHttp([ + { method: "GET", path: "/1.5/crm/custom_fields", status: 200, body: [] }, + { + method: "POST", + path: "/1.5/crm/custom_fields", + status: 200, + body: { + id: "8", + name: "HubSpot Contact", + type: "EXTERNAL_ID", + config: { + url_template: "https://app.hubspot.com/contacts/123/record/0-1/{value}", + }, + }, + }, + ]); + }, + }, { toolName: "leadbay_import_status", arguments: { importIds: ["imp-1"] }, @@ -768,7 +817,7 @@ describe("structuredContent conformance — every outputSchema declarer (iter17) }); expect( (result as any).isError, - `${c.toolName} returned isError — happy-path mock incomplete` + `${c.toolName} returned isError — happy-path mock incomplete: ${JSON.stringify((result as any).content)}` ).not.toBe(true); const structured = (result as any).structuredContent; diff --git a/packages/mcp/test/prompts.test.ts b/packages/mcp/test/prompts.test.ts index 9120e579..a86de38d 100644 --- a/packages/mcp/test/prompts.test.ts +++ b/packages/mcp/test/prompts.test.ts @@ -1,5 +1,5 @@ /** - * Prompts test — verifies the prompts/* capability + 5 canned slash + * Prompts test — verifies the prompts/* capability + canned slash * commands. */ @@ -28,13 +28,14 @@ async function connect() { } describe("prompts/* capability (P2 prompts)", () => { - it("prompts/list returns all 5 canned prompts", async () => { + it("prompts/list returns all canned prompts", async () => { const { mcpClient } = await connect(); const listed = await mcpClient.listPrompts(); const names = listed.prompts.map((p) => p.name); expect(names).toEqual([ "leadbay_daily_check_in", "leadbay_research_a_domain", + "leadbay_import_file", "leadbay_refine_audience", "leadbay_log_outreach", "leadbay_qualify_top_n", @@ -68,6 +69,25 @@ describe("prompts/* capability (P2 prompts)", () => { expect(text).toContain("leadbay_import_and_qualify"); }); + it("prompts/get(import_file) teaches resolve-disambiguate-import flow", async () => { + const { mcpClient } = await connect(); + const result = await mcpClient.getPrompt({ + name: "leadbay_import_file", + arguments: { file: "leads.csv", instruction: "then qualify them" }, + }); + const text = (result.messages[0].content as any).text; + expect(text).toContain("leads.csv"); + expect(text).toContain("leadbay_resolve_import_rows"); + expect(text).toContain("include_candidate_profiles"); + expect(text).toContain("leadbay_import_and_qualify"); + expect(text).toContain("Never choose from score alone"); + expect(text).toContain("business domain"); + expect(text).toContain("CONTACT_EMAIL"); + expect(text).toContain("leadbay_create_custom_field"); + expect(text).toContain("EXTERNAL_ID"); + expect(text).toContain("HubSpot"); + }); + it("prompts/get with missing required argument errors", async () => { const { mcpClient } = await connect(); let threw = false; From bc1170d8fec5a0bb204557636ef3d07e7057f299 Mon Sep 17 00:00:00 2001 From: milstan Date: Thu, 14 May 2026 20:27:31 -0700 Subject: [PATCH 2/2] Fix Leadclaw import tool contracts --- packages/core/src/composite/resolve-import-rows.ts | 14 +++++++++----- packages/leadclaw/openclaw.plugin.json | 2 ++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/core/src/composite/resolve-import-rows.ts b/packages/core/src/composite/resolve-import-rows.ts index 9bbfe3bf..7b0868a9 100644 --- a/packages/core/src/composite/resolve-import-rows.ts +++ b/packages/core/src/composite/resolve-import-rows.ts @@ -232,12 +232,16 @@ export const resolveImportRows: Tool