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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/composite/import-and-qualify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>' 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 " +
Expand Down Expand Up @@ -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.<id>'. 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.<id> " +
"fields created or discovered before import.",
},
custom_fields: {
type: "object",
Expand Down
46 changes: 37 additions & 9 deletions packages/core/src/composite/import-leads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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"
);
}
Expand Down Expand Up @@ -1272,8 +1282,8 @@ export const importLeads: Tool<ImportLeadsParams, ImportLeadsToolResult> = {
"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" +
Expand All @@ -1289,10 +1299,24 @@ export const importLeads: Tool<ImportLeadsParams, ImportLeadsToolResult> = {
"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.<id>' (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,
Expand Down Expand Up @@ -1345,8 +1369,12 @@ export const importLeads: Tool<ImportLeadsParams, ImportLeadsToolResult> = {
"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.<id>' 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.<id> " +
"field returned by leadbay_create_custom_field when no suitable custom field already exists.",
},
custom_fields: {
type: "object",
Expand Down
Loading
Loading