@leadbay/mcp 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -532,6 +532,444 @@ function parseRetryAfter(value) {
532
532
  return null;
533
533
  }
534
534
 
535
+ // ../core/dist/tool-descriptions.generated.js
536
+ var leadbay_account_status = `Show the user's account state \u2014 admin rights, language, last-active lens, current quota usage across daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's intelligence is mid-regeneration. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption.
537
+
538
+ WHEN TO USE: at the start of a session to know what the agent can/can't do, or after a 429 to explain to the user which resource window was exhausted and when it resets.
539
+
540
+ WHEN NOT TO USE: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating.
541
+ `;
542
+ var leadbay_add_note = `Add a note to a lead. Notes are visible to the whole organization in Leadbay.
543
+
544
+ WHEN TO USE: low-level \u2014 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.
545
+
546
+ WHEN NOT TO USE: to log an outreach action \u2014 use leadbay_report_outreach, which requires verification (gmail/calendar/user_confirmed) to prevent hallucinated outreach poisoning the SDR pipeline.
547
+
548
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
549
+ `;
550
+ var leadbay_adjust_audience = `Restrict (or expand) the lens audience by sector / size. Free-text sectors are auto-resolved against the sector taxonomy; ambiguous matches are surfaced to the agent rather than guessed silently. Permission routing is hidden: the default lens auto-clones to a new user lens; an org-level lens defaults to a per-user draft (admins can override with \`save_for_org:true\`). Filter MERGES with existing criteria (unrelated criteria are not dropped).
551
+
552
+ WHEN TO USE: when the user wants to see different kinds of leads (sector / size / etc.).
553
+
554
+ WHEN NOT TO USE: to refine BEYOND firmographics \u2014 that's leadbay_refine_prompt.
555
+
556
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
557
+ `;
558
+ var leadbay_answer_clarification = `Answer the pending clarification question Leadbay raised after a refine_prompt. The answer is stored as the new \`user_prompt\` and triggers regeneration. Pass \`option_id\` (preferred \u2014 pick from the offered options) or \`text_answer\` (free-text). Admin-only.
559
+
560
+ WHEN TO USE: after leadbay_refine_prompt returns \`status='clarification_pending'\`.
561
+
562
+ WHEN NOT TO USE: to set a brand-new prompt \u2014 use leadbay_refine_prompt.
563
+
564
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
565
+ `;
566
+ var leadbay_bulk_enrich_status = `Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Returns the \`bulk_id\`, progress per lead (done/total enrichable contacts), and overall progress. When \`include_contacts=true\` (opt-in), includes each contact's email/phone/job_title/enrichment.done.
567
+
568
+ WHEN TO USE: poll this after leadbay_enrich_titles returns a \`bulk_id\`. Default \`include_contacts=false\` for cheap status polls; set \`include_contacts=true\` once \`all_done\` flips for the final read.
569
+
570
+ WHEN NOT TO USE: as a substitute for leadbay_research_lead \u2014 that already includes enriched contacts for a single lead.
571
+ `;
572
+ var leadbay_bulk_qualify_leads = `Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass \`wait_for_completion:false\` to return quickly with \`{status:'running', qualify_id}\`; poll leadbay_qualify_status with that id. With \`wait_for_completion\` omitted/true, the legacy behavior polls until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null \`ai_agent_lead_score\`) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads.
573
+
574
+ Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads below the top ~10 are NOT worse \u2014 the system is saving resources. This tool is how the agent spends more resources to go deeper on promising-looking leads the user hasn't had time to surface yet.
575
+
576
+ WHEN TO USE: when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty \`qualification_summary\`.
577
+
578
+ WHEN NOT TO USE: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).
579
+
580
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
581
+ `;
582
+ var leadbay_clear_selection = `Clear the user's transient selection.
583
+
584
+ WHEN TO USE: cleanup after manual selection work, or recovery from a stuck composite.
585
+
586
+ WHEN NOT TO USE: in normal flow \u2014 composites clear in their own finally blocks.
587
+
588
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
589
+ `;
590
+ var leadbay_clear_user_prompt = `Remove the org's intelligence-refinement prompt (revert to AI-only generation). Admin-only. Triggers full intelligence regeneration.
591
+
592
+ WHEN TO USE: when a refinement turned out to be the wrong direction.
593
+
594
+ WHEN NOT TO USE: to replace with a different prompt \u2014 just call leadbay_refine_prompt; that overwrites.
595
+
596
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
597
+ `;
598
+ var leadbay_create_custom_field = `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.
599
+
600
+ 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/<portal-id>/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.
601
+
602
+ WHEN TO USE: after leadbay_list_mappable_fields shows no suitable existing custom field and preserving the column matters to the user's goal.
603
+
604
+ 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.
605
+ `;
606
+ var leadbay_create_lens = `Create a new user-level lens by cloning an existing lens's filter/scoring as the starting point.
607
+
608
+ WHEN TO USE: when adjust_audience determined the current lens cannot be edited (e.g. it's the org default).
609
+
610
+ WHEN NOT TO USE: to update an existing lens \u2014 use leadbay_update_lens or leadbay_update_lens_filter.
611
+
612
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
613
+ `;
614
+ var leadbay_create_lens_draft = `Create (or fetch existing) draft of an org-level lens. Idempotent \u2014 same user calling twice returns the same draft. The returned lens has \`draft_of\` set to the original lens id.
615
+
616
+ WHEN TO USE: when a non-admin needs to modify an org-level lens \u2014 make a draft, edit the draft.
617
+
618
+ WHEN NOT TO USE: from agent flow \u2014 leadbay_adjust_audience handles the draft-routing transparently.
619
+
620
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
621
+ `;
622
+ var leadbay_deselect_leads = `Remove leads from the user's transient selection.
623
+
624
+ WHEN TO USE: when narrowing a previously-built selection without clearing it entirely.
625
+
626
+ WHEN NOT TO USE: in normal flow \u2014 leadbay_enrich_titles handles selection lifecycle.
627
+
628
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
629
+ `;
630
+ var leadbay_discover_leads = `Get AI-recommended leads from Leadbay. Returns paginated lead summaries with scores, AI summaries, tags, and recommended contacts. Auto-resolves to the active lens if \`lensId\` is omitted; \`count\` is capped at 50 per page.
631
+
632
+ WHEN TO USE: low-level \u2014 when you need raw paginated wishlist access without the qualification_summary attached by leadbay_pull_leads.
633
+
634
+ WHEN NOT TO USE: as the agent's default lead-discovery entry point \u2014 use leadbay_pull_leads, which adds a one-line qualification summary per lead.
635
+ `;
636
+ var leadbay_dismiss_clarification = `Dismiss the pending clarification without answering. Leadbay proceeds with its best guess. Admin-only.
637
+
638
+ WHEN TO USE: when the user explicitly doesn't want to answer the disambiguation.
639
+
640
+ WHEN NOT TO USE: as a default \u2014 answering with even a free-text reason gives Leadbay better signal.
641
+
642
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
643
+ `;
644
+ var leadbay_enrich_contacts = `Order email and/or phone enrichment for a specific contact. Performs an advisory credit check, then tries the paid-contact path and falls back to the org-contact path on NOT_FOUND. Consumes enrichment credits.
645
+
646
+ WHEN TO USE: when you have a specific \`contact_id\` (from leadbay_get_contacts) and want to enrich just that one.
647
+
648
+ WHEN NOT TO USE: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow.
649
+
650
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
651
+ `;
652
+ var leadbay_enrich_titles = `Order contact enrichments by job title across many leads. Contacts are NOT returned by default with a lead (Leadbay keeps enrichment out-of-band to control cost); the agent requests them on demand via this tool when it's ready to actually reach out. Two modes: (A) NO \`titles\` param \u2014 returns the available titles + Leadbay's \`title_suggestions\` + \`auto_included_titles\` + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) \`titles\` given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns \`{status:'quota_exceeded'}\` cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error.
653
+
654
+ WHEN TO USE: as the agent's go-to enrichment entry point, immediately before proposing outreach.
655
+
656
+ WHEN NOT TO USE: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular). Speculatively, before the user has committed to outreaching \u2014 enrichment spends credits.
657
+
658
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
659
+ `;
660
+ var leadbay_get_clarification = `Check whether Leadbay has a pending clarification question \u2014 a question raised when refining the intelligence prompt produced contradictory or ambiguous criteria. Returns \`{pending: false, clarification: null}\` when nothing is pending (the backend returns 204).
661
+
662
+ WHEN TO USE: after leadbay_refine_prompt, to see if Leadbay needs the user to disambiguate.
663
+
664
+ WHEN NOT TO USE: to answer the question \u2014 use leadbay_answer_clarification.
665
+ `;
666
+ var leadbay_get_contacts = `Get contacts for a lead, including enriched email and phone data. Returns both organization contacts and enrichable contacts with IDs, tagged with \`source:'org'|'paid'\`.
667
+
668
+ WHEN TO USE: to check enrichment status (\`contact.enrichment.done\`) on individual leads after a bulk enrichment was launched, or to find the \`contact_id\` needed by leadbay_enrich_contacts.
669
+
670
+ WHEN NOT TO USE: as a substitute for leadbay_research_lead, which already includes enriched contacts in its return.
671
+ `;
672
+ var leadbay_get_enrichment_job_titles = `List the actual job titles present across the leads currently in the user's selection \u2014 the candidate set the user can ask to enrich.
673
+
674
+ WHEN TO USE: after leadbay_select_leads, to know which titles are even available before launching a bulk enrichment.
675
+
676
+ WHEN NOT TO USE: standalone \u2014 the selection must already be populated, otherwise the result is an empty array. leadbay_enrich_titles wraps this whole flow when you don't need to inspect the title list manually.
677
+ `;
678
+ var leadbay_get_epilogue_responses = `Read the lead's epilogue history \u2014 what status (still chasing, meeting booked, etc.) was set when, and by whom. Paginated (\`count\` 1-200 default 20, \`page\` 0-indexed).
679
+
680
+ WHEN TO USE: to see the lead's outreach progression before deciding the next step.
681
+
682
+ WHEN NOT TO USE: when the lead summary's \`epilogue_actions_count\` is 0.
683
+ `;
684
+ var leadbay_get_lead_activities = `Get prospecting activity history for a lead (emails sent, calls made, status changes, notes). Each entry is \`{type, date}\`; older activities are trimmed by \`count\` (max 100, default 50).
685
+
686
+ WHEN TO USE: to avoid redundant outreach and understand where this lead is in the sales process.
687
+
688
+ WHEN NOT TO USE: when leadbay_research_lead has already been called \u2014 it includes recent prospecting actions in its engagement block.
689
+ `;
690
+ var leadbay_get_lead_notes = `Read existing notes on a lead \u2014 context the human team or prior agent runs have already captured.
691
+
692
+ WHEN TO USE: before adding a note via leadbay_report_outreach, to avoid duplicating or overwriting context the SDR already wrote.
693
+
694
+ WHEN NOT TO USE: when the lead summary's \`notes_count\` is 0 \u2014 there's nothing to fetch.
695
+ `;
696
+ var leadbay_get_lead_profile = `Get a full lead profile including company details, AI qualification scores, web insights, and contacts. Also marks the lead as SEEN+CLICKED in the user's lens so it ages out of the "new" Discover view (fire-and-forget; profile fetch never blocks on it).
697
+
698
+ WHEN TO USE: low-level \u2014 for fine-grained access to the raw shape of the lead profile.
699
+
700
+ WHEN NOT TO USE: as the agent's default lead-detail tool \u2014 use leadbay_research_lead, which structures the data top-down (qualification first, then signals, then firmographics, then contacts, then engagement) and reshapes web_fetch.content into a stable array form.
701
+ `;
702
+ var leadbay_get_lens_filter = `Read the firmographic filter (sectors, sizes, locations) currently applied to a lens.
703
+
704
+ WHEN TO USE: before adjusting an audience \u2014 see what's already restricted so changes are diffs, not full replacements.
705
+
706
+ WHEN NOT TO USE: to actually apply changes \u2014 use the leadbay_adjust_audience composite, which handles permissions transparently.
707
+ `;
708
+ var leadbay_get_lens_scoring = `Read the AI-scoring criteria configured on a lens (what makes a lead score 100 vs 30).
709
+
710
+ WHEN TO USE: when explaining why a lead got the score it did.
711
+
712
+ WHEN NOT TO USE: to mutate scoring \u2014 that's an admin/setup operation, not part of the agent loop.
713
+ `;
714
+ var leadbay_get_prospecting_actions = `Read the CRM-style activity log for a lead (calls, emails, meetings \u2014 actions performed by humans or prior agent runs). Paginated (\`count\` 1-200 default 20, \`page\` 0-indexed).
715
+
716
+ WHEN TO USE: before contacting the lead, to avoid duplicating outreach the team already did.
717
+
718
+ WHEN NOT TO USE: when the lead summary's \`prospecting_actions_count\` is 0.
719
+ `;
720
+ var leadbay_get_quota = `Read remaining quota / spend across daily, weekly, and monthly windows for the org's resources (\`llm_completion\`, \`ai_rescore\`, \`web_fetch\`). Each entry shows \`current_units\` vs \`max_units\` and \`resets_at\`.
721
+
722
+ WHEN TO USE: after a 429 error, to explain to the user which window was hit and when it resets.
723
+
724
+ WHEN NOT TO USE: as a pre-flight gate before bulk operations \u2014 operations themselves return 429 with hints; this tool is for diagnostics, not gating.
725
+ `;
726
+ var leadbay_get_selection_ids = `List the lead ids currently in the user's selection (the transient set that bulk operations like enrichment act on).
727
+
728
+ WHEN TO USE: to verify the selection state before/after bulk ops if a composite call has misbehaved.
729
+
730
+ WHEN NOT TO USE: in the normal flow \u2014 leadbay_enrich_titles manages selection lifecycle automatically (select \u2192 action \u2192 clear).
731
+ `;
732
+ var leadbay_get_taste_profile = `Get the user's Ideal Buyer Profile, purchase-intent tags, and qualification questions. The result is cached on the client. Returns an operator \`hint\` when no profile is configured yet.
733
+
734
+ WHEN TO USE: at the very start of a session to understand what kind of leads the user is looking for.
735
+
736
+ WHEN NOT TO USE: per-lead \u2014 leadbay_research_lead already includes the per-lead qualification answers (which are scored against these org-level questions).
737
+ `;
738
+ var leadbay_get_user_prompt = `Read the org's intelligence-refinement prompt (free-text instruction that steers lead recommendations beyond firmographics). Returns \`{prompt: null, set: false}\` when none is configured (the backend returns 204 in that case).
739
+
740
+ WHEN TO USE: to know what's currently steering the agent's recommendations before suggesting a refine.
741
+
742
+ WHEN NOT TO USE: to set/change the prompt \u2014 use leadbay_refine_prompt.
743
+ `;
744
+ var leadbay_get_web_fetch = `Read the AI-generated web-research summary for a lead \u2014 company profile, business signals, prospecting clues, each with sources and "hot" flags marking high-signal recent items. The content is dictioned by emoji-prefixed section labels in the raw API.
745
+
746
+ WHEN TO USE: when the agent already qualified this lead and wants the underlying research to reason from.
747
+
748
+ WHEN NOT TO USE: as the first read on a lead \u2014 the leadbay_research_lead composite bundles this with qualification answers and reshapes the dict into a stable array form.
749
+ `;
750
+ var leadbay_import_and_qualify = `Import + qualify leads in one call. Pass either \`domains: [{domain, name?}]\` (Mode A) OR \`records[]\` with \`mappings\` (Mode B). At least one mapped field must be LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE. Discover the org's mappable surface via \`leadbay_list_mappable_fields\`. For messy files, prefer the \`leadbay_import_file\` prompt which walks an agent through scan \u2192 resolve \u2192 preserve \u2192 commit phases.
751
+
752
+ WHEN TO USE: agent has a list of companies (domains, or CSV-shaped rows from the user's CRM) and wants the full AI qualification \u2014 qualification answers, web-research signals \u2014 without orchestrating import + bulk_qualify_leads + lead_profile chains by hand.
753
+
754
+ WHEN NOT TO USE: discovery (use leadbay_pull_leads); single-lead deep dive (use leadbay_research_lead); high-cadence or untrusted automation \u2014 this mutates user state and consumes ai_rescore + web_fetch quota.
755
+
756
+ Budgets: \`total_budget_ms\` caps wall-clock; \`per_lead_budget_ms\` caps each lead's poll. For short transport timeouts, pass \`wait_for_completion:false\` and poll \`leadbay_import_status\`. Outputs \`qualified[]\`, \`still_running[]\`, \`not_imported[]\`, \`qualify_id\` (resumable handle). Idempotent within a 5-min window. \`dry_run:'preview'\` returns mapping hints + custom-field candidates without importing.
757
+
758
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
759
+
760
+
761
+ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role; active billing. Imported leads are NOT auto-promoted to the Monitor view; lens-scoring threshold decides.
762
+ `;
763
+ var leadbay_import_leads = `Import leads into Leadbay's CRM via the file-import wizard. Returns stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead. For MCP clients with short transport timeouts, pass \`wait_for_completion:false\` to return quickly with \`{status:'running', handle_id}\`; poll leadbay_import_status with that handle. For end-to-end import+qualify in one call, prefer leadbay_import_and_qualify. For messy files, prefer the \`leadbay_import_file\` prompt which walks an agent through scan \u2192 resolve \u2192 preserve \u2192 commit phases.
764
+
765
+ TWO MODES: (A) Domain-list shortcut \u2014 pass \`domains: [{domain, name?}]\`. The tool builds a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and imports with the default mapping. (B) Custom records + mapping \u2014 pass \`records: [{Col1, Col2, ...}]\` plus \`mappings.fields: {Col1: 'LEAD_NAME', ...}\`. \`mappings.fields\` must include LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE (resolver needs at least one identity key). Pass exactly one of \`domains\` / \`records\`. Reserved column \`MCP_ROW_ID\` cannot appear in records/mappings \u2014 the tool injects it for stable reconciliation.
766
+
767
+ MUTATES USER STATE: each call creates a row in the user's CRM-imports list (visible in the web UI) and touches onboarding state. Suitable for occasional automation, NOT for high-cadence (>5 calls/day). Imported leads are NOT auto-promoted to the user's Monitor view; lens-scoring threshold decides. For messy files call leadbay_resolve_import_rows first, then pass \`records_for_import\`/\`mappings_for_import\` here. Agents should inspect every column, build a preservation plan, and pass an explicit final mapping. For each meaningful column decide standard field, CONTACT_* field, Leadbay note, custom field, derived helper, or skip with a reason. For contact-only exports, derive a company-domain column from CONTACT_EMAIL only when it's a real business domain. Multiple rows can share the same LEADBAY_ID and import as separate contacts on that lead. Custom fields use \`CUSTOM.<id>\` in \`mappings.fields\` or the \`mappings.custom_fields\` shorthand. For source-system deep links create a custom field via leadbay_create_custom_field first (prefer EXTERNAL_ID + url_template). Preserve meaningful per-lead notes by calling leadbay_add_note after import returns lead IDs.
768
+
769
+ WHEN TO USE: you have a list of company domains from another system (CRM, analytics, email correspondents) and need stable Leadbay leadIds; or CRM-shaped rows with custom columns and want to drive the wizard with explicit field mappings.
770
+
771
+ 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; when you also want qualification in the same call (use leadbay_import_and_qualify).
772
+
773
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
774
+
775
+
776
+ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the Leadbay account; active billing.
777
+ `;
778
+ var leadbay_import_status = `Retrieve the current state of an async lead import. Pass \`handle_id\` returned by \`leadbay_import_leads({wait_for_completion:false})\`, or pass legacy \`importIds[]\` to inspect backend wizard rows. This status call performs a single refresh pass and never polls in a loop.
779
+
780
+ WHEN TO USE: after leadbay_import_leads or leadbay_import_and_qualify returns \`{status:'running', handle_id}\` for the import phase, call this tool later to retrieve progress or the final import result without re-running the import.
781
+
782
+ WHEN NOT TO USE: for qualification handles returned as \`qualify_id\` \u2014 use leadbay_qualify_status for those; or when you still want the legacy blocking behavior from leadbay_import_leads with \`wait_for_completion=true\`.
783
+ `;
784
+ var leadbay_launch_bulk_enrichment = `Launch a bulk-enrichment job against the current selection. The backend requires \`email=true\` OR \`phone=true\` (both can be true). Returns 204 with no body \u2014 there is no bulk_id and no per-job status endpoint. Track results by polling individual leads via leadbay_get_contacts after ~60s; \`contact.enrichment.done\` flips to true. \`dry_run:true\` returns the call shape without contacting the backend.
785
+
786
+ WHEN TO USE: low-level.
787
+
788
+ WHEN NOT TO USE: from agent flow \u2014 leadbay_enrich_titles handles selection lifecycle, preview, launch, and cleanup.
789
+
790
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
791
+ `;
792
+ var leadbay_list_lenses = `List all available Leadbay lenses (saved lead-search configurations). Each lens defines a different target market or buyer segment. The lens with \`is_last_active=true\` is used by default for lead discovery.
793
+
794
+ WHEN TO USE: when the user wants to switch lens or asks "what lenses do I have".
795
+
796
+ WHEN NOT TO USE: in normal flow \u2014 composites auto-resolve the active lens via \`/me.last_requested_lens\`.
797
+ `;
798
+ var leadbay_list_mappable_fields = `List every CRM field the agent can target when calling leadbay_import_leads or leadbay_import_and_qualify. Returns two arrays: \`standard_fields\` (Leadbay's built-in StandardCrmFieldType enum \u2014 LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, contact + location + sector fields) and \`custom_fields\` (this org's user-defined fields \u2014 id, name, type, and the literal \`mapping_value\` you pass in \`mappings.fields\`). For custom fields, \`mapping_value\` is the wire-format string \`CUSTOM.<id>\` \u2014 pass it verbatim.
799
+
800
+ 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.
801
+
802
+ 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). Saves a separate preview round-trip.
803
+
804
+ WHEN TO USE: before authoring an import mapping, especially when the CSV has columns that aren't obvious matches for standard fields.
805
+
806
+ WHEN NOT TO USE: when you already know the mapping \u2014 this call is cheap (~50ms without for_records, ~5\u201310s with) but unnecessary if the agent has already cached the catalog within the same conversation.
807
+ `;
808
+ var leadbay_list_sectors = `List the sector taxonomy (id + display name in the requested language). Default: \`lang\` follows the caller's language; \`includeInvisible=false\` returns ~1,091 visible sectors.
809
+
810
+ WHEN TO USE: to resolve a free-text sector name (e.g. "Healthcare") into the sector ids that leadbay_adjust_audience needs.
811
+
812
+ WHEN NOT TO USE: when you already have sector ids \u2014 pass them directly.
813
+ `;
814
+ var leadbay_login = `Log in to Leadbay with email and password. Auto-detects region (us|fr) \u2014 the user does not need to know which backend their account lives on. On success, sets the client's bearer token and switches the active region if needed.
815
+
816
+ WHEN TO USE: at the start of a session if no token is preconfigured (cfg.token / LEADBAY_TOKEN).
817
+
818
+ WHEN NOT TO USE: if a token is already preconfigured \u2014 you'll just overwrite it. The user needs a Leadbay account first; they can register at https://wow.leadbay.ai/?register=true.
819
+
820
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
821
+ `;
822
+ var leadbay_pick_clarification = `Answer the pending clarification question \u2014 either by picking one of the offered options (\`option_id\`) or by typing a free-text answer. The answer is stored as the new user_prompt and triggers regeneration. Admin-only.
823
+
824
+ WHEN TO USE: low-level.
825
+
826
+ WHEN NOT TO USE: from agent flow \u2014 use leadbay_answer_clarification.
827
+
828
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
829
+ `;
830
+ var leadbay_prepare_outreach = `Prepare an outreach package for a single lead: recommended contact + enriched contact details + AI summary. Optionally trigger contact enrichment in-flight (\`enrich:true\`); enrichment is async, so poll leadbay_get_contacts after ~60s if you need the result inline.
831
+
832
+ WHEN TO USE: when the agent is about to draft outreach for ONE specific lead and needs the contact's email/phone.
833
+
834
+ WHEN NOT TO USE: across many leads \u2014 use leadbay_enrich_titles for bulk; for general lead detail use leadbay_research_lead (richer signals); to actually log the outreach action use leadbay_report_outreach (requires verification).
835
+ `;
836
+ var leadbay_preview_bulk_enrichment = `Preview a bulk-enrichment cost given a set of job titles applied to the current selection. Returns \`{selected_leads, enriched_contacts, enrichable_contacts, title_suggestions, auto_included_titles, previously_enriched_titles}\`. \`previously_enriched_titles\` is a newer field (in prod soon) \u2014 when present, the agent can recommend repeating those titles for new leads.
837
+
838
+ WHEN TO USE: between selecting leads and launching, to know what the enrichment will cost.
839
+
840
+ WHEN NOT TO USE: from agent flow \u2014 leadbay_enrich_titles wraps preview + launch with the right safety checks.
841
+ `;
842
+ var leadbay_promote_lens = `Promote a user-level lens (or draft) to org-level so all teammates see it. Admin-only.
843
+
844
+ WHEN TO USE: rare \u2014 when an admin user has built a lens (or refined a draft) and wants to share it org-wide.
845
+
846
+ WHEN NOT TO USE: as a non-admin (will fail with 403); for personal lens changes (those stay user-scoped).
847
+
848
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
849
+ `;
850
+ var leadbay_pull_leads = `Pull up new leads from the user's last-active lens \u2014 the canonical "show me today's prospects" tool. Leadbay works like an inbox: each time the user logs back in, a fresh batch is delivered, paced by how many leads they've actually acted on recently. Pulling more won't produce more; user outreach/skips/saves does. Each returned lead carries a one-line \`qualification_summary\` built from leadbay_ai_agent_responses, plus the rich tags / scores / recommended_contact_title / engagement counters / in-flight flags from the lead summary.
851
+
852
+ Roughly the top 10 of the batch come pre-qualified (populated qualification_summary + ai_agent_lead_score); leads below the top ~10 carry only the basic firmographic \`score\` \u2014 not worse, just resource-saved by the system. Call leadbay_bulk_qualify_leads to deepen any of them on demand.
853
+
854
+ WHEN TO USE: as the agent's default opening move when the user wants to see leads, or as a daily check-in for what's new today.
855
+
856
+ WHEN NOT TO USE: when the user has named a specific lens \u2014 pass \`lensId\` to override the auto-resolution. Replaces the older leadbay_find_prospects (removed in v0.2.0).
857
+ `;
858
+ var leadbay_qualify_lead = `Trigger AI qualification for a single lead (web fetch + AI rescore). The operation is asynchronous \u2014 results take ~60s. \`forceFetch:true\` re-runs even if recent data exists.
859
+
860
+ WHEN TO USE: low-level \u2014 when you need to kick qualification on exactly one lead without composite orchestration.
861
+
862
+ WHEN NOT TO USE: as the agent's bulk-qualify path \u2014 use leadbay_bulk_qualify_leads, which paginates past already-qualified leads, fans out, polls, and bails out cleanly on 429.
863
+
864
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
865
+ `;
866
+ var leadbay_qualify_status = `Retrieve the current state of an import_and_qualify (or bulk_qualify_leads) launch by \`qualify_id\`. Returns the same \`qualified[]\` / \`still_running[]\` shape as the original composite, refreshed against the backend at call time. The handle is persisted to \`~/.leadbay/bulks.json\` with a 30-day TTL and survives MCP restart.
867
+
868
+ WHEN TO USE: after leadbay_import_and_qualify or leadbay_bulk_qualify_leads returned a \`qualify_id\` with non-empty \`still_running[]\`, call this tool a few minutes later (or hours) to retrieve the now-completed qualifications without re-running the import or re-spending qualify quota.
869
+
870
+ WHEN NOT TO USE: as a substitute for leadbay_research_lead \u2014 that's a deeper per-lead profile and includes contacts. This tool is purely the qualification answers + signals_count.
871
+ `;
872
+ var leadbay_recall_ordered_titles = `Show job titles the org has previously enriched, so the agent can repeat the same titles for new leads (or skip already-saturated ones). Two implementation paths: (1) PREFERRED \u2014 a selection-scoped preview call that reads \`previously_enriched_titles\` from the backend (newer prod field). (2) FALLBACK \u2014 live aggregation across each lead's enriched contacts. The composite picks transparently.
873
+
874
+ WHEN TO USE: before leadbay_enrich_titles, to plan which titles to order.
875
+
876
+ WHEN NOT TO USE: when you already know the exact titles you want to enrich.
877
+ `;
878
+ var leadbay_refine_prompt = `Refine the kind of leads Leadbay surfaces, beyond firmographics. Free-text instruction (e.g. "focus on hospitals running their own IT"). Sets the org's \`user_prompt\`; if the new prompt produces ambiguous criteria, Leadbay raises a clarification question, which this composite polls for and surfaces. Admin-only on the backend (will return 403 for non-admins).
879
+
880
+ WHEN TO USE: when audience filters (leadbay_adjust_audience) aren't enough.
881
+
882
+ WHEN NOT TO USE: to answer a pending clarification \u2014 that's leadbay_answer_clarification.
883
+
884
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
885
+ `;
886
+ var leadbay_remove_epilogue = `Bulk-clear the epilogue status from a set of leads.
887
+
888
+ WHEN TO USE: when an outreach action was logged in error and needs to be undone.
889
+
890
+ WHEN NOT TO USE: to change status \u2014 call leadbay_set_epilogue_status with the new status (it overwrites).
891
+
892
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
893
+ `;
894
+ var leadbay_report_outreach = `Log an outreach action (email, call, message, meeting) on a lead so the human team using Leadbay sees the progress in their UI. Writes a NOTE on the lead and (optionally) sets an EPILOGUE status (still chasing, meeting booked, etc.). Bulk variant: pass \`lead_ids=[uuid,...]\` instead of \`lead_id\` (epilogue is bulk-native; notes fan out per-lead).
895
+
896
+ VERIFICATION REQUIRED: every call must include \`verification={source: 'gmail_message_id'|'calendar_event_id'|'user_confirmed', ref: '<id-or-confirmation>'}\` to prevent hallucinated outreach poisoning the pipeline. The verification is appended to the note body. Skipping or fabricating verification poisons the human team's pipeline.
897
+
898
+ WHEN TO USE: AFTER actually emailing/calling/meeting/messaging a contact, OR after a substantive decision the user wants logged (skip, save, hand off).
899
+
900
+ WHEN NOT TO USE: BEFORE doing the outreach (use \`dry_run:true\` to validate args first); without verification (call will be rejected); from a flow where the user did not consent to having actions logged automatically.
901
+
902
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
903
+ `;
904
+ var leadbay_research_company = `Deep-dive research on a specific company by NAME (fuzzy match against the active lens's wishlist). Pass \`companyName\` (matches the top-scoring lead with that name) or \`leadId\` (takes precedence when both supplied).
905
+
906
+ WHEN TO USE: when the user references a company by name and you don't yet have its \`lead_id\`.
907
+
908
+ WHEN NOT TO USE: when you already have the lead_id \u2014 use leadbay_research_lead directly (it bundles richer signals + better top-down ordering for the agent).
909
+ `;
910
+ var leadbay_research_lead = `Tell me everything decision-relevant about a single lead. Bundles the lens-scoped lead profile, the AI qualification answers (the agent's knowledge-base food), the structured web-research signals (with hot flags + sources), the enriched contacts, and the recent notes/epilogue/prospecting activity in one call. Order is deliberate: qualification first, then signals, then firmographics, then contacts, then engagement.
911
+
912
+ Scoring has two layers: the basic \`score\` (firmographic, always present, already decent) and the AI qualification layer (\`ai_agent_lead_score\` + per-question answers + web_fetch signals). The AI layer is pre-populated for roughly the top 10 of each daily batch, and on-demand (via leadbay_bulk_qualify_leads) for anything below that. Combine both layers when judging a lead.
913
+
914
+ WHEN TO USE: when picking up a single lead from leadbay_pull_leads to decide whether to act on it.
915
+
916
+ WHEN NOT TO USE: across many leads at once \u2014 that's leadbay_pull_leads' job. (This composite supersedes the lower-level leadbay_get_lead_profile in agent flow; the granular tool stays available for fine-grained access.)
917
+ `;
918
+ var leadbay_resolve_import_rows = `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\`.
919
+
920
+ WHEN TO USE: before importing user-supplied files when domains, names, CRM IDs, registry numbers, or Leadbay IDs may be inconsistently formatted; when the agent needs to pre-resolve messy rows, inspect ambiguous candidates, or prepare LEADBAY_ID values for the import composites. 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.
921
+
922
+ WHEN NOT TO USE: for prospect discovery from scratch (use leadbay_pull_leads); for one known company profile (use leadbay_research_company / leadbay_research_lead); or when the file already has clean, final LEADBAY_ID/CRM_ID/SIREN mappings and no row-level identity disambiguation is needed.
923
+ `;
924
+ var leadbay_select_leads = `Add leads to the user's transient selection (used by selection-scoped bulk operations). Accepts 1-1000 \`leadIds\` per call.
925
+
926
+ WHEN TO USE: low-level. The user's selection is a per-token global state \u2014 be careful when invoking directly.
927
+
928
+ WHEN NOT TO USE: in normal flow \u2014 leadbay_enrich_titles wraps select \u2192 action \u2192 clear in one call with proper Mutex protection. Calling this directly without acquiring the selection lock can clobber concurrent composite calls.
929
+
930
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
931
+ `;
932
+ var leadbay_set_active_lens = `Mark a lens as last-used. Subsequent \`/me\` reads return it as \`last_requested_lens\`, so all composite tools default to it.
933
+
934
+ WHEN TO USE: after the user explicitly switched contexts (e.g. created a new lens via leadbay_create_lens).
935
+
936
+ WHEN NOT TO USE: in normal flow \u2014 leadbay_pull_leads and leadbay_adjust_audience auto-set the right lens.
937
+
938
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
939
+ `;
940
+ var leadbay_set_epilogue_status = `Bulk-set the outreach progress (epilogue) status across a set of leads. Status values: \`STILL_CHASING\`, \`COULD_NOT_REACH_STILL_TRYING\`, \`INTEREST_VALIDATED_OR_MEETING_PLANED\` ("meeting booked"), \`NOT_INTERESTED_LOST\` (short labels accepted; mapped to the \`EPILOGUE_*\` enum). Up to 1000 leads per call.
941
+
942
+ WHEN TO USE: low-level.
943
+
944
+ WHEN NOT TO USE: from agent flow \u2014 leadbay_report_outreach pairs this with a note + verification, which is what humans actually need to see in Leadbay.
945
+
946
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
947
+ `;
948
+ var leadbay_set_user_prompt = `Set the org's intelligence-refinement prompt \u2014 free-text instruction that steers Leadbay's lead recommendations beyond firmographics. Admin-only. Setting this clears any pending clarification and triggers a full intelligence regeneration (web search + high-reasoning). \`dry_run:true\` returns the call shape without contacting the backend.
949
+
950
+ WHEN TO USE: low-level.
951
+
952
+ WHEN NOT TO USE: from agent flow \u2014 use leadbay_refine_prompt, which polls for follow-up clarifications.
953
+
954
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
955
+ `;
956
+ var leadbay_update_lens = `Update lens metadata (name, description, mode flags). Does NOT change the audience filter \u2014 use leadbay_update_lens_filter for that.
957
+
958
+ WHEN TO USE: rename a lens or toggle \`multi_product_mode\` / \`use_hq_only\`.
959
+
960
+ WHEN NOT TO USE: to change which leads the lens shows \u2014 that's a filter operation.
961
+
962
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
963
+ `;
964
+ var leadbay_update_lens_filter = `Replace the audience filter (sectors, sizes, locations) on a lens. Body is the full \`Filter\` object \u2014 this is a REPLACE, not a merge. Returns 400 \`default_lens\` if applied to the org default lens (clone it first). \`dry_run:true\` returns the call shape without contacting the backend.
965
+
966
+ WHEN TO USE: low-level mutation when you've already prepared the merged filter.
967
+
968
+ WHEN NOT TO USE: from agent flow \u2014 use leadbay_adjust_audience, which handles draft-vs-direct routing, permission fallback, and the merge logic so unrelated criteria aren't dropped.
969
+
970
+ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible for confirming intent before invocation; the MCP server does not soft-prompt for confirmation. See \`annotations.destructiveHint\`.
971
+ `;
972
+
535
973
  // ../core/dist/tools/login.js
536
974
  var login = {
537
975
  name: "leadbay_login",
@@ -542,7 +980,7 @@ var login = {
542
980
  idempotentHint: false,
543
981
  openWorldHint: true
544
982
  },
545
- description: "Log in to Leadbay with email and password. Auto-detects region (us|fr) \u2014 the user does not need to know which backend their account lives on. When to use: at the start of a session if no token is preconfigured (cfg.token / LEADBAY_TOKEN). When NOT to use: if a token is already preconfigured (you'll just overwrite it). The user needs a Leadbay account \u2014 they can register at https://wow.leadbay.ai/?register=true",
983
+ description: leadbay_login,
546
984
  inputSchema: {
547
985
  type: "object",
548
986
  properties: {
@@ -593,7 +1031,7 @@ var listLenses = {
593
1031
  idempotentHint: true,
594
1032
  openWorldHint: true
595
1033
  },
596
- description: "List all available Leadbay lenses (saved lead search configurations). Each lens defines a different target market or buyer segment. The lens with is_last_active=true is used by default for lead discovery. When to use: when the user wants to switch lens or asks 'what lenses do I have'. When NOT to use: in normal flow \u2014 composites auto-resolve the active lens via /me.last_requested_lens.",
1034
+ description: leadbay_list_lenses,
597
1035
  inputSchema: {
598
1036
  type: "object",
599
1037
  properties: {},
@@ -641,7 +1079,7 @@ var discoverLeads = {
641
1079
  idempotentHint: true,
642
1080
  openWorldHint: true
643
1081
  },
644
- description: "Get AI-recommended leads from Leadbay. Returns paginated lead summaries with scores, AI summaries, tags, and recommended contacts. When to use: low-level when you need raw paginated wishlist access without the qualification_summary attached by leadbay_pull_leads. When NOT to use: as the agent's default lead-discovery entry point \u2014 use leadbay_pull_leads, which adds a one-line qualification summary per lead.",
1082
+ description: leadbay_discover_leads,
645
1083
  inputSchema: {
646
1084
  type: "object",
647
1085
  properties: {
@@ -705,7 +1143,7 @@ var getLeadProfile = {
705
1143
  idempotentHint: true,
706
1144
  openWorldHint: true
707
1145
  },
708
- description: "Get a full lead profile including company details, AI qualification scores, web insights, and contacts. When to use: low-level \u2014 for fine-grained access to the raw shape of the lead profile. When NOT to use: as the agent's default lead-detail tool \u2014 use leadbay_research_lead, which structures the data top-down (qualification first, then signals, then firmographics, then contacts, then engagement) and reshapes web_fetch.content into a stable array form.",
1146
+ description: leadbay_get_lead_profile,
709
1147
  inputSchema: {
710
1148
  type: "object",
711
1149
  properties: {
@@ -837,7 +1275,7 @@ var getContacts = {
837
1275
  idempotentHint: true,
838
1276
  openWorldHint: true
839
1277
  },
840
- description: "Get contacts for a lead, including enriched email and phone data. Returns both organization contacts and enrichable contacts with IDs. When to use: to check enrichment status (contact.enrichment.done) on individual leads after a bulk enrichment was launched, or to find the contact_id needed by leadbay_enrich_contacts. When NOT to use: as a substitute for leadbay_research_lead, which already includes enriched contacts in its return.",
1278
+ description: leadbay_get_contacts,
841
1279
  inputSchema: {
842
1280
  type: "object",
843
1281
  properties: {
@@ -897,7 +1335,7 @@ var getQuota = {
897
1335
  idempotentHint: true,
898
1336
  openWorldHint: true
899
1337
  },
900
- description: "Read remaining quota / spend across daily, weekly, monthly windows for the org's resources (llm_completion, ai_rescore, web_fetch). Each entry shows current_units vs max_units and resets_at. When to use: after a 429 error, to explain to the user which window was hit and when it resets. When NOT to use: as a pre-flight gate before bulk operations \u2014 operations themselves return 429 with hints; this tool is for diagnostics, not gating.",
1338
+ description: leadbay_get_quota,
901
1339
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
902
1340
  outputSchema: {
903
1341
  type: "object",
@@ -926,7 +1364,7 @@ var getTasteProfile = {
926
1364
  idempotentHint: true,
927
1365
  openWorldHint: true
928
1366
  },
929
- description: "Get the user's Ideal Buyer Profile, purchase intent tags, and qualification questions. When to use: at the very start of a session to understand what kind of leads the user is looking for. Data is cached. When NOT to use: per-lead \u2014 leadbay_research_lead already includes the per-lead qualification answers (which are scored against these org-level questions).",
1367
+ description: leadbay_get_taste_profile,
930
1368
  inputSchema: {
931
1369
  type: "object",
932
1370
  properties: {},
@@ -990,7 +1428,7 @@ var qualifyLead = {
990
1428
  idempotentHint: true,
991
1429
  openWorldHint: true
992
1430
  },
993
- description: "Trigger AI qualification for a single lead (web fetch + AI rescore). The operation is asynchronous \u2014 results take ~60s. When to use: low-level. When NOT to use: as the agent's bulk-qualify path \u2014 use leadbay_bulk_qualify_leads, which paginates past already-qualified leads, fan-outs, polls, and bails out cleanly on 429.",
1431
+ description: leadbay_qualify_lead,
994
1432
  optional: true,
995
1433
  inputSchema: {
996
1434
  type: "object",
@@ -1027,7 +1465,7 @@ var enrichContacts = {
1027
1465
  idempotentHint: true,
1028
1466
  openWorldHint: true
1029
1467
  },
1030
- description: "Order email and/or phone enrichment for a specific contact. When to use: when you have a specific contact_id (from leadbay_get_contacts) and want to enrich just that one. When NOT to use: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow.",
1468
+ description: leadbay_enrich_contacts,
1031
1469
  optional: true,
1032
1470
  inputSchema: {
1033
1471
  type: "object",
@@ -1101,7 +1539,7 @@ var addNote = {
1101
1539
  idempotentHint: false,
1102
1540
  openWorldHint: true
1103
1541
  },
1104
- description: "Add a note to a lead. Notes are visible to the whole organization in Leadbay. When to use: low-level \u2014 for free-form notes not tied to outreach actions. When NOT to use: to log an outreach action \u2014 use leadbay_report_outreach, which requires verification (gmail/calendar/user_confirmed) to prevent hallucinated outreach poisoning the SDR pipeline.",
1542
+ description: leadbay_add_note,
1105
1543
  optional: true,
1106
1544
  inputSchema: {
1107
1545
  type: "object",
@@ -1151,7 +1589,7 @@ var getLeadActivities = {
1151
1589
  idempotentHint: true,
1152
1590
  openWorldHint: true
1153
1591
  },
1154
- description: "Get prospecting activity history for a lead (emails sent, calls made, status changes, notes). When to use: to avoid redundant outreach and understand where this lead is in the sales process. When NOT to use: when leadbay_research_lead has already been called \u2014 it includes recent prospecting actions in its engagement block.",
1592
+ description: leadbay_get_lead_activities,
1155
1593
  inputSchema: {
1156
1594
  type: "object",
1157
1595
  properties: {
@@ -1211,7 +1649,7 @@ var getLensFilter = {
1211
1649
  idempotentHint: true,
1212
1650
  openWorldHint: true
1213
1651
  },
1214
- description: "Read the firmographic filter (sectors, sizes, locations) currently applied to a lens. When to use: before adjusting an audience \u2014 see what's already restricted so changes are diffs, not full replacements. When NOT to use: to actually apply changes \u2014 use the leadbay_adjust_audience composite, which handles permissions transparently.",
1652
+ description: leadbay_get_lens_filter,
1215
1653
  inputSchema: {
1216
1654
  type: "object",
1217
1655
  properties: {
@@ -1235,7 +1673,7 @@ var getLensScoring = {
1235
1673
  idempotentHint: true,
1236
1674
  openWorldHint: true
1237
1675
  },
1238
- description: "Read the AI-scoring criteria configured on a lens (what makes a lead score 100 vs 30). When to use: when explaining why a lead got the score it did. When NOT to use: to mutate scoring \u2014 that's an admin/setup operation, not part of the agent loop.",
1676
+ description: leadbay_get_lens_scoring,
1239
1677
  inputSchema: {
1240
1678
  type: "object",
1241
1679
  properties: { lensId: { type: "number", description: "Lens id (required)" } },
@@ -1257,7 +1695,7 @@ var listSectors = {
1257
1695
  idempotentHint: true,
1258
1696
  openWorldHint: true
1259
1697
  },
1260
- description: "List the sector taxonomy (id + display name in the requested language). When to use: to resolve a free-text sector name (e.g. 'Healthcare') into the sector ids that leadbay_adjust_audience needs. Default: lang follows the caller's language; includeInvisible=false returns ~1,091 visible sectors. When NOT to use: when you already have sector ids \u2014 pass them directly.",
1698
+ description: leadbay_list_sectors,
1261
1699
  inputSchema: {
1262
1700
  type: "object",
1263
1701
  properties: {
@@ -1295,7 +1733,7 @@ var getUserPrompt = {
1295
1733
  idempotentHint: true,
1296
1734
  openWorldHint: true
1297
1735
  },
1298
- description: "Read the org's intelligence-refinement prompt (free-text instruction that steers lead recommendations beyond firmographics). Returns null if none is set (the backend returns 204 in that case). When to use: to know what's currently steering the agent's recommendations before suggesting a refine. When NOT to use: to set/change the prompt \u2014 use leadbay_refine_prompt.",
1736
+ description: leadbay_get_user_prompt,
1299
1737
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1300
1738
  outputSchema: {
1301
1739
  type: "object",
@@ -1335,7 +1773,7 @@ var getClarification = {
1335
1773
  idempotentHint: true,
1336
1774
  openWorldHint: true
1337
1775
  },
1338
- description: "Check whether Leadbay has a pending clarification question \u2014 a question raised when refining the intelligence prompt produced contradictory or ambiguous criteria. Returns null when nothing is pending (the backend returns 204). When to use: after leadbay_refine_prompt, to see if Leadbay needs the user to disambiguate. When NOT to use: to answer the question \u2014 use leadbay_answer_clarification.",
1776
+ description: leadbay_get_clarification,
1339
1777
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1340
1778
  outputSchema: {
1341
1779
  type: "object",
@@ -1377,7 +1815,7 @@ var getLeadNotes = {
1377
1815
  idempotentHint: true,
1378
1816
  openWorldHint: true
1379
1817
  },
1380
- description: "Read existing notes on a lead \u2014 context the human team or prior agent runs have already captured. When to use: before adding a note via leadbay_report_outreach, to avoid duplicating or overwriting context the SDR already wrote. When NOT to use: when the lead summary's notes_count is 0 \u2014 there's nothing to fetch.",
1818
+ description: leadbay_get_lead_notes,
1381
1819
  inputSchema: {
1382
1820
  type: "object",
1383
1821
  properties: { leadId: { type: "string", description: "Lead UUID (required)" } },
@@ -1399,7 +1837,7 @@ var getEpilogueResponses = {
1399
1837
  idempotentHint: true,
1400
1838
  openWorldHint: true
1401
1839
  },
1402
- description: "Read the lead's epilogue history \u2014 what status (still chasing, meeting booked, etc.) was set when, and by whom. When to use: to see the lead's outreach progression before deciding the next step. When NOT to use: when the lead summary's epilogue_actions_count is 0.",
1840
+ description: leadbay_get_epilogue_responses,
1403
1841
  inputSchema: {
1404
1842
  type: "object",
1405
1843
  properties: {
@@ -1427,7 +1865,7 @@ var getProspectingActions = {
1427
1865
  idempotentHint: true,
1428
1866
  openWorldHint: true
1429
1867
  },
1430
- description: "Read the CRM-style activity log for a lead (calls, emails, meetings \u2014 actions performed by humans or prior agent runs). When to use: before contacting the lead, to avoid duplicating outreach the team already did. When NOT to use: when the lead summary's prospecting_actions_count is 0.",
1868
+ description: leadbay_get_prospecting_actions,
1431
1869
  inputSchema: {
1432
1870
  type: "object",
1433
1871
  properties: {
@@ -1455,7 +1893,7 @@ var getWebFetch = {
1455
1893
  idempotentHint: true,
1456
1894
  openWorldHint: true
1457
1895
  },
1458
- description: "Read the AI-generated web-research summary for a lead \u2014 company profile, business signals, prospecting clues, each with sources and 'hot' flags marking high-signal recent items. The content is dictioned by emoji-prefixed section labels in the raw API. When to use: when the agent already qualified this lead and wants the underlying research to reason from. When NOT to use: as the first read on a lead \u2014 the leadbay_research_lead composite bundles this with qualification answers and reshapes the dict into a stable array form.",
1896
+ description: leadbay_get_web_fetch,
1459
1897
  inputSchema: {
1460
1898
  type: "object",
1461
1899
  properties: { leadId: { type: "string", description: "Lead UUID (required)" } },
@@ -1498,7 +1936,7 @@ var getSelectionIds = {
1498
1936
  idempotentHint: true,
1499
1937
  openWorldHint: true
1500
1938
  },
1501
- description: "List the lead ids currently in the user's selection (the transient set that bulk operations like enrichment act on). When to use: to verify the selection state before/after bulk ops if a composite call has misbehaved. When NOT to use: in the normal flow \u2014 leadbay_enrich_titles manages selection lifecycle automatically (select \u2192 action \u2192 clear).",
1939
+ description: leadbay_get_selection_ids,
1502
1940
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1503
1941
  execute: async (client) => {
1504
1942
  return await client.request("GET", "/leads/selection/ids");
@@ -1515,7 +1953,7 @@ var getEnrichmentJobTitles = {
1515
1953
  idempotentHint: true,
1516
1954
  openWorldHint: true
1517
1955
  },
1518
- description: "List the actual job titles present across the leads currently in the user's selection \u2014 the candidate set the user can ask to enrich. When to use: after leadbay_select_leads, to know which titles are even available before launching a bulk enrichment. When NOT to use: standalone \u2014 the selection must already be populated, otherwise the result is an empty array. leadbay_enrich_titles wraps this whole flow when you don't need to inspect the title list manually.",
1956
+ description: leadbay_get_enrichment_job_titles,
1519
1957
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
1520
1958
  execute: async (client) => {
1521
1959
  return await client.request("GET", "/leads/selection/enrichment/job_titles");
@@ -1871,7 +2309,10 @@ async function refreshLeadStates(client, leadIds, questionOrder) {
1871
2309
  }
1872
2310
 
1873
2311
  // ../core/dist/composite/import-leads.js
1874
- import { randomUUID } from "crypto";
2312
+ import { createHash as createHash2, randomUUID } from "crypto";
2313
+ function isImportLeadsRunningResult(result) {
2314
+ return "status" in result && result.status === "running";
2315
+ }
1875
2316
  var CHUNK_SIZE = 100;
1876
2317
  var POLL_INTERVAL_MS2 = 2e3;
1877
2318
  var DEFAULT_PER_PHASE_BUDGET_MS = 6e4;
@@ -1880,6 +2321,13 @@ var STABILIZATION_POLLS = 2;
1880
2321
  var MAX_COLUMN_NAME_LEN = 128;
1881
2322
  var RESERVED_COLUMN_RE = /^mcp_row_id$/i;
1882
2323
  var CUSTOM_FIELD_RE = /^CUSTOM\.(\d+)$/;
2324
+ var IMPORT_RESOLVER_FIELDS = /* @__PURE__ */ new Set([
2325
+ "LEADBAY_ID",
2326
+ "CRM_ID",
2327
+ "LEAD_NAME",
2328
+ "LEAD_WEBSITE",
2329
+ "SIREN"
2330
+ ]);
1883
2331
  function isCustomFieldMappingValue(v) {
1884
2332
  return CUSTOM_FIELD_RE.test(v);
1885
2333
  }
@@ -1972,6 +2420,29 @@ function chunkAt100(items) {
1972
2420
  }
1973
2421
  return chunks;
1974
2422
  }
2423
+ function stableStringify(value) {
2424
+ if (value === null || typeof value !== "object")
2425
+ return JSON.stringify(value);
2426
+ if (Array.isArray(value))
2427
+ return `[${value.map(stableStringify).join(",")}]`;
2428
+ const obj = value;
2429
+ return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
2430
+ }
2431
+ function importFingerprint(params, prep) {
2432
+ const payload = {
2433
+ mode: prep.mode,
2434
+ rows: prep.validInputs.map((i) => ({
2435
+ domain: i.domain,
2436
+ outputDomain: i.outputDomain,
2437
+ row: i.row
2438
+ })),
2439
+ malformed: prep.malformedDomains,
2440
+ header: prep.header,
2441
+ mappings: prep.mappings,
2442
+ dry_run: Boolean(params.dry_run)
2443
+ };
2444
+ return createHash2("sha256").update(stableStringify(payload)).digest("hex");
2445
+ }
1975
2446
  function checkAborted(signal) {
1976
2447
  if (signal?.aborted) {
1977
2448
  throw Object.assign(new Error("aborted"), { name: "AbortError" });
@@ -2099,8 +2570,8 @@ function prepareRecordsMode(client, records, mappings, customFieldCatalog) {
2099
2570
  throw client.makeError("IMPORT_MAPPING_REQUIRED", "mappings.fields must contain at least one column \u2192 CRM field entry", "Map at least one CSV column to LEAD_NAME or LEAD_WEBSITE.", "POST /imports");
2100
2571
  }
2101
2572
  const targets = new Set(fieldEntries.map(([, v]) => v));
2102
- if (!targets.has("LEAD_NAME") && !targets.has("LEAD_WEBSITE")) {
2103
- 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.", "POST /imports");
2573
+ if (![...targets].some((t) => IMPORT_RESOLVER_FIELDS.has(t))) {
2574
+ throw client.makeError("IMPORT_MAPPING_NO_RESOLVER", "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");
2104
2575
  }
2105
2576
  const targetCounts = /* @__PURE__ */ new Map();
2106
2577
  for (const [col, target] of fieldEntries) {
@@ -2339,6 +2810,10 @@ async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCoun
2339
2810
  }
2340
2811
  }
2341
2812
  async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId) {
2813
+ const upload = await uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx, onImportId);
2814
+ return completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal);
2815
+ }
2816
+ async function uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx, onImportId) {
2342
2817
  const csv = synthesizeCsv(header, chunk.map((c) => c.row));
2343
2818
  const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2344
2819
  const fileName = `mcp-import-${ts}-${chunkIdx}.csv`;
@@ -2346,6 +2821,10 @@ async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mapping
2346
2821
  const upload = await client.requestRawBinary("POST", `/imports?file_name=${encodeURIComponent(fileName)}`, "text/csv", csv);
2347
2822
  const importId = upload.id;
2348
2823
  onImportId(importId);
2824
+ return { importId, chunk, chunkIdx, totalChunks };
2825
+ }
2826
+ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal) {
2827
+ const { importId, chunk } = upload;
2349
2828
  const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
2350
2829
  await pollPreprocess(client, importId, phaseBudget, ctx, signal);
2351
2830
  ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
@@ -2416,6 +2895,82 @@ function reconcileOneChunk(prep, chunk, matched, notImported) {
2416
2895
  }
2417
2896
  }
2418
2897
  }
2898
+ function buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled) {
2899
+ const leads = [];
2900
+ const not_imported = [];
2901
+ if (dryRun) {
2902
+ for (const inp of prep.validInputs) {
2903
+ if (prep.mode === "domains") {
2904
+ not_imported.push({ domain: inp.outputDomain, reason: "dry_run" });
2905
+ } else {
2906
+ const entry = { rowId: inp.rowId, reason: "dry_run" };
2907
+ if (inp.outputDomain)
2908
+ entry.domain = inp.outputDomain;
2909
+ not_imported.push(entry);
2910
+ }
2911
+ }
2912
+ } else {
2913
+ for (const inp of prep.validInputs) {
2914
+ const m = matched.get(inp.index);
2915
+ if (m) {
2916
+ if (prep.mode === "domains") {
2917
+ leads.push({
2918
+ domain: inp.outputDomain,
2919
+ leadId: m.leadId,
2920
+ name: m.name
2921
+ });
2922
+ } else {
2923
+ const e = {
2924
+ rowId: inp.rowId,
2925
+ leadId: m.leadId,
2926
+ name: m.name
2927
+ };
2928
+ if (m.domain ?? inp.outputDomain)
2929
+ e.domain = m.domain ?? inp.outputDomain;
2930
+ leads.push(e);
2931
+ }
2932
+ continue;
2933
+ }
2934
+ const ni = notImported.get(inp.index);
2935
+ if (ni) {
2936
+ if (prep.mode === "domains") {
2937
+ not_imported.push({ domain: inp.outputDomain, reason: ni.reason });
2938
+ } else {
2939
+ const e = { rowId: inp.rowId, reason: ni.reason };
2940
+ if (ni.domain ?? inp.outputDomain)
2941
+ e.domain = ni.domain ?? inp.outputDomain;
2942
+ not_imported.push(e);
2943
+ }
2944
+ continue;
2945
+ }
2946
+ if (prep.mode === "domains") {
2947
+ not_imported.push({ domain: inp.outputDomain, reason: "internal_error" });
2948
+ } else {
2949
+ const e = { rowId: inp.rowId, reason: "internal_error" };
2950
+ if (inp.outputDomain)
2951
+ e.domain = inp.outputDomain;
2952
+ not_imported.push(e);
2953
+ }
2954
+ }
2955
+ }
2956
+ for (const m of prep.malformedDomains) {
2957
+ not_imported.push({ domain: m, reason: "malformed" });
2958
+ }
2959
+ return {
2960
+ leads,
2961
+ not_imported,
2962
+ importIds,
2963
+ region: client.region,
2964
+ cancelled: cancelled || void 0,
2965
+ dry_run: dryRun || void 0,
2966
+ _meta: client.lastMeta ?? {
2967
+ region: client.region,
2968
+ endpoint: "POST /imports",
2969
+ latency_ms: null,
2970
+ retry_after: null
2971
+ }
2972
+ };
2973
+ }
2419
2974
  var importLeads = {
2420
2975
  name: "leadbay_import_leads",
2421
2976
  annotations: {
@@ -2428,7 +2983,7 @@ var importLeads = {
2428
2983
  idempotentHint: true,
2429
2984
  openWorldHint: true
2430
2985
  },
2431
- description: "Import leads into Leadbay's CRM via the file-import wizard. Returns stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead.\n\nTWO MODES:\n A) Domain-list shortcut \u2014 pass `domains: [{domain, name?}]`. The tool builds a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and imports with the default mapping. Output: { leads: [{domain, leadId, name}], not_imported: [{domain, reason}], importIds, _meta }.\n B) Custom records + mapping \u2014 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}], not_imported: [{rowId, domain?, reason}], importIds, _meta }. `rowId` round-trips your input order.\n\nPass exactly one of `domains` / `records`. Reserved column MCP_ROW_ID (any case) cannot appear in records or mappings \u2014 the tool injects it for stable reconciliation.\n\n\u26A0\uFE0F MUTATES USER STATE. Each call:\n - creates a row in the user's CRM-imports list (visible in the web UI)\n - touches onboarding state (startFileless, onboarding step \u2192 PROCESSING)\nSuitable for occasional automation. NOT suitable for high-cadence (>5 calls/day) \u2014 wait for the backend programmatic endpoint (issue: leadbay/backend prolonged-import-with-crawl).\n\n\u2139\uFE0F Monitor-tab membership: imported leads are NOT auto-promoted to the user's Monitor view. Lens-scoring decides \u2014 only above-threshold leads get `in_monitor: true` server-side.\n\nWhen to use: you have a list of company domains from another system (CRM, analytics, email correspondents) and need stable Leadbay leadIds; or you have CRM-shaped rows with custom columns (sector, location, status, etc.) and want to drive the wizard with explicit field mappings.\nWhen 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\nCustom 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\nRequires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the Leadbay account; active billing.",
2986
+ description: leadbay_import_leads,
2432
2987
  write: true,
2433
2988
  version: "0.3.0",
2434
2989
  inputSchema: {
@@ -2466,7 +3021,7 @@ var importLeads = {
2466
3021
  properties: {
2467
3022
  fields: {
2468
3023
  type: "object",
2469
- description: "Object whose keys are CSV column names (matching keys in `records`) and whose values are either Leadbay's StandardCrmFieldType (LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, LEAD_LOCATION, LEAD_LOCATION_*, LEAD_SECTOR, LEAD_SIZE, CRM_ID, LEADBAY_ID, EMAIL, 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 \u2014 the wizard needs that to find leads. Use leadbay_list_mappable_fields to discover the org's custom fields."
3024
+ description: "Object whose keys are CSV column names (matching keys in `records`) and whose values are either Leadbay's StandardCrmFieldType (LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, LEAD_LOCATION, LEAD_LOCATION_*, LEAD_SECTOR, LEAD_SIZE, CRM_ID, LEADBAY_ID, EMAIL, 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 LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE \u2014 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."
2470
3025
  },
2471
3026
  custom_fields: {
2472
3027
  type: "object",
@@ -2494,6 +3049,10 @@ var importLeads = {
2494
3049
  total_budget_ms: {
2495
3050
  type: "number",
2496
3051
  description: `Overall cap across all phases (default ${DEFAULT_TOTAL_BUDGET_MS}ms).`
3052
+ },
3053
+ wait_for_completion: {
3054
+ type: "boolean",
3055
+ description: "When false, validate and enqueue the import in the background, then return `{status:'running', handle_id}` immediately. Poll leadbay_import_status(handle_id). Default is true for 0.6.x backwards compatibility."
2497
3056
  }
2498
3057
  },
2499
3058
  // Neither field is "required" at the schema level; xor + presence is
@@ -2508,6 +3067,18 @@ var importLeads = {
2508
3067
  description: "Imported leads. Domains mode: [{domain, leadId, name}]. Records mode: [{rowId, domain?, leadId, name}].",
2509
3068
  items: { type: "object" }
2510
3069
  },
3070
+ status: {
3071
+ type: "string",
3072
+ description: "`running` when wait_for_completion=false; absent on the legacy blocking result."
3073
+ },
3074
+ handle_id: {
3075
+ type: "string",
3076
+ description: "Persisted UUID handle to pass to leadbay_import_status."
3077
+ },
3078
+ progress: {
3079
+ type: "object",
3080
+ description: "Current async import progress when wait_for_completion=false."
3081
+ },
2511
3082
  not_imported: {
2512
3083
  type: "array",
2513
3084
  description: "Inputs that did NOT yield a leadId. Each entry has a `reason` ('malformed', 'NO_MATCH', 'TIMEOUT', etc.) plus the input echo.",
@@ -2529,7 +3100,11 @@ var importLeads = {
2529
3100
  },
2530
3101
  _meta: { type: "object" }
2531
3102
  },
2532
- required: ["leads", "not_imported", "importIds", "region", "_meta"]
3103
+ required: ["importIds", "region", "_meta"],
3104
+ anyOf: [
3105
+ { required: ["leads", "not_imported", "importIds", "region", "_meta"] },
3106
+ { required: ["status", "handle_id", "importIds", "progress", "region", "_meta"] }
3107
+ ]
2533
3108
  },
2534
3109
  execute: async (client, params, ctx) => {
2535
3110
  const signal = ctx?.signal;
@@ -2537,6 +3112,7 @@ var importLeads = {
2537
3112
  const perPhaseBudget = params.per_phase_budget_ms ?? DEFAULT_PER_PHASE_BUDGET_MS;
2538
3113
  const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS;
2539
3114
  const totalDeadline = Date.now() + totalBudget;
3115
+ const waitForCompletion = params.wait_for_completion ?? true;
2540
3116
  const hasDomains = Array.isArray(params.domains) && params.domains.length > 0;
2541
3117
  const hasRecords = Array.isArray(params.records) && params.records.length > 0;
2542
3118
  if (hasDomains && hasRecords) {
@@ -2563,13 +3139,13 @@ var importLeads = {
2563
3139
  }
2564
3140
  const prep = hasDomains ? prepareDomainsMode(client, params.domains) : prepareRecordsMode(client, params.records, params.mappings, customFieldCatalog);
2565
3141
  if (prep.validInputs.length === 0) {
2566
- const not_imported2 = prep.malformedDomains.map((d) => ({
3142
+ const not_imported = prep.malformedDomains.map((d) => ({
2567
3143
  domain: d,
2568
3144
  reason: "malformed"
2569
3145
  }));
2570
3146
  return {
2571
3147
  leads: [],
2572
- not_imported: not_imported2,
3148
+ not_imported,
2573
3149
  importIds: [],
2574
3150
  region: client.region,
2575
3151
  dry_run: dryRun || void 0,
@@ -2582,6 +3158,67 @@ var importLeads = {
2582
3158
  };
2583
3159
  }
2584
3160
  const chunks = chunkAt100(prep.validInputs);
3161
+ if (!waitForCompletion) {
3162
+ if (!ctx?.bulkTracker) {
3163
+ throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_leads wait_for_completion=false needs a BulkTracker so the handle survives restart.", "");
3164
+ }
3165
+ const reservation = await ctx.bulkTracker.findOrCreatePendingImport({
3166
+ import_fingerprint: importFingerprint(params, prep),
3167
+ mode: prep.mode,
3168
+ dry_run: dryRun,
3169
+ records_total: prep.validInputs.length
3170
+ });
3171
+ const importIds2 = [...reservation.record.import_ids];
3172
+ const uploadedChunks = [];
3173
+ if (!reservation.reused || reservation.record.import_ids.length === 0) {
3174
+ try {
3175
+ for (let i = 0; i < chunks.length; i++) {
3176
+ const upload = await uploadOneChunk(client, chunks[i], i, chunks.length, prep.header, ctx, (id) => {
3177
+ if (!importIds2.includes(id))
3178
+ importIds2.push(id);
3179
+ });
3180
+ uploadedChunks.push(upload);
3181
+ await ctx.bulkTracker.setImportIds(reservation.record.bulk_id, importIds2);
3182
+ }
3183
+ await ctx.bulkTracker.setImportProgress(reservation.record.bulk_id, {
3184
+ phase: "preprocess",
3185
+ records_processed: 0,
3186
+ records_total: prep.validInputs.length
3187
+ });
3188
+ } catch (err) {
3189
+ await ctx.bulkTracker.markImportFailed(reservation.record.bulk_id, err?.message ?? err?.code ?? "unknown");
3190
+ throw err;
3191
+ }
3192
+ }
3193
+ if (uploadedChunks.length > 0) {
3194
+ void runImportInBackground(client, prep, uploadedChunks, {
3195
+ dryRun,
3196
+ perPhaseBudget,
3197
+ totalBudget
3198
+ }, ctx, reservation.record.bulk_id);
3199
+ }
3200
+ return {
3201
+ status: "running",
3202
+ handle_id: reservation.record.bulk_id,
3203
+ importIds: importIds2,
3204
+ progress: {
3205
+ phase: reservation.record.status === "complete" ? "complete" : importIds2.length > 0 ? "preprocess" : "queued",
3206
+ records_processed: reservation.record.status === "complete" ? reservation.record.records_total : 0,
3207
+ records_total: reservation.record.records_total
3208
+ },
3209
+ region: client.region,
3210
+ ...reservation.reused ? {
3211
+ reused: true,
3212
+ seconds_since_original: reservation.seconds_since_original
3213
+ } : {},
3214
+ _meta: client.lastMeta ?? {
3215
+ region: client.region,
3216
+ endpoint: "POST /imports",
3217
+ latency_ms: null,
3218
+ retry_after: null
3219
+ }
3220
+ };
3221
+ }
2585
3222
  ctx?.logger?.info?.(`import-leads(${prep.mode}): ${prep.validInputs.length} rows \u2192 ${chunks.length} chunk(s); dry_run=${dryRun}, totalBudgetMs=${totalBudget}`);
2586
3223
  const importIds = [];
2587
3224
  const matched = /* @__PURE__ */ new Map();
@@ -2615,88 +3252,51 @@ var importLeads = {
2615
3252
  throw err;
2616
3253
  }
2617
3254
  }
2618
- const leads = [];
2619
- const not_imported = [];
2620
- if (dryRun) {
2621
- for (const inp of prep.validInputs) {
2622
- if (prep.mode === "domains") {
2623
- not_imported.push({ domain: inp.outputDomain, reason: "dry_run" });
2624
- } else {
2625
- const entry = { rowId: inp.rowId, reason: "dry_run" };
2626
- if (inp.outputDomain)
2627
- entry.domain = inp.outputDomain;
2628
- not_imported.push(entry);
2629
- }
2630
- }
2631
- } else {
2632
- for (const inp of prep.validInputs) {
2633
- const m = matched.get(inp.index);
2634
- if (m) {
2635
- if (prep.mode === "domains") {
2636
- leads.push({
2637
- domain: inp.outputDomain,
2638
- leadId: m.leadId,
2639
- name: m.name
2640
- });
2641
- } else {
2642
- const e = {
2643
- rowId: inp.rowId,
2644
- leadId: m.leadId,
2645
- name: m.name
2646
- };
2647
- if (m.domain ?? inp.outputDomain)
2648
- e.domain = m.domain ?? inp.outputDomain;
2649
- leads.push(e);
2650
- }
2651
- continue;
2652
- }
2653
- const ni = notImported.get(inp.index);
2654
- if (ni) {
2655
- if (prep.mode === "domains") {
2656
- not_imported.push({ domain: inp.outputDomain, reason: ni.reason });
2657
- } else {
2658
- const e = { rowId: inp.rowId, reason: ni.reason };
2659
- if (ni.domain ?? inp.outputDomain)
2660
- e.domain = ni.domain ?? inp.outputDomain;
2661
- not_imported.push(e);
3255
+ return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled);
3256
+ }
3257
+ };
3258
+ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, handleId) {
3259
+ const tracker = ctx.bulkTracker;
3260
+ if (!tracker)
3261
+ return;
3262
+ void tracker.setImportProgress(handleId, {
3263
+ phase: "preprocess",
3264
+ records_processed: 0,
3265
+ records_total: prep.validInputs.length
3266
+ }).catch(() => {
3267
+ });
3268
+ setTimeout(() => {
3269
+ void (async () => {
3270
+ const bgCtx = { logger: ctx.logger, bulkTracker: tracker };
3271
+ const importIds = uploadedChunks.map((chunk) => chunk.importId);
3272
+ const matched = /* @__PURE__ */ new Map();
3273
+ const notImported = /* @__PURE__ */ new Map();
3274
+ try {
3275
+ const totalDeadline = Date.now() + opts.totalBudget;
3276
+ for (const upload of uploadedChunks) {
3277
+ const out = await completeUploadedChunk(client, upload, prep.mappings, opts.dryRun, opts.perPhaseBudget, totalDeadline, bgCtx, void 0);
3278
+ if (!opts.dryRun) {
3279
+ reconcileOneChunk(prep, out, matched, notImported);
2662
3280
  }
2663
- continue;
2664
- }
2665
- if (prep.mode === "domains") {
2666
- not_imported.push({ domain: inp.outputDomain, reason: "internal_error" });
2667
- } else {
2668
- const e = { rowId: inp.rowId, reason: "internal_error" };
2669
- if (inp.outputDomain)
2670
- e.domain = inp.outputDomain;
2671
- not_imported.push(e);
2672
3281
  }
3282
+ const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false);
3283
+ await tracker.markImportComplete(handleId, {
3284
+ leads: result.leads,
3285
+ not_imported: result.not_imported,
3286
+ importIds: result.importIds
3287
+ });
3288
+ } catch (err) {
3289
+ await tracker.markImportFailed(handleId, err?.message ?? err?.code ?? "unknown");
2673
3290
  }
2674
- }
2675
- for (const m of prep.malformedDomains) {
2676
- not_imported.push({ domain: m, reason: "malformed" });
2677
- }
2678
- return {
2679
- leads,
2680
- not_imported,
2681
- importIds,
2682
- region: client.region,
2683
- cancelled: cancelled || void 0,
2684
- dry_run: dryRun || void 0,
2685
- _meta: client.lastMeta ?? {
2686
- region: client.region,
2687
- endpoint: "POST /imports",
2688
- latency_ms: null,
2689
- retry_after: null
2690
- }
2691
- };
2692
- }
2693
- };
3291
+ })();
3292
+ }, 0);
3293
+ }
2694
3294
 
2695
3295
  // ../core/dist/tools/list-mappable-fields.js
2696
3296
  var STANDARD_FIELDS = [
2697
3297
  { name: "LEAD_NAME", description: "Company name. Required for fuzzy match." },
2698
3298
  { name: "LEAD_WEBSITE", description: "Company domain (preferred matcher; protocol/path auto-stripped)." },
2699
- { name: "EMAIL", description: "Email \u2014 domain part used as a website-fallback matcher." },
3299
+ { name: "EMAIL", description: "Lead/company email \u2014 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." },
2700
3300
  { name: "CRM_ID", description: "Your CRM's stable lead identifier (round-trips back as crm_id on the lead)." },
2701
3301
  { name: "LEADBAY_ID", description: "Leadbay UUID, if you already have one (matches by id, no fuzzy needed)." },
2702
3302
  { name: "DEAL_CRM_ID", description: "Your CRM's deal id (one deal per row; combined with LEAD_STATUS forms a sales record)." },
@@ -2714,7 +3314,7 @@ var STANDARD_FIELDS = [
2714
3314
  { name: "SIREN", description: "French SIREN registry number (9 digits) \u2014 auto-matches against the FR registry." },
2715
3315
  { name: "CONTACT_FIRST_NAME", description: "Contact first name (creates an org contact)." },
2716
3316
  { name: "CONTACT_LAST_NAME", description: "Contact last name." },
2717
- { name: "CONTACT_EMAIL", description: "Contact email." },
3317
+ { 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." },
2718
3318
  { name: "CONTACT_PHONE_NUMBER", description: "Contact phone (free-form)." },
2719
3319
  { name: "CONTACT_TITLE", description: "Contact job title." },
2720
3320
  { name: "CONTACT_LINKEDIN", description: "Contact LinkedIn URL." }
@@ -2732,7 +3332,7 @@ function describeCustomField(f) {
2732
3332
  case "DATETIME":
2733
3333
  return `Custom DATETIME field${f.config?.format ? ` (format: ${f.config.format})` : " (ISO datetime)"}.`;
2734
3334
  case "EXTERNAL_ID":
2735
- return `Custom EXTERNAL_ID field \u2014 opaque id${f.config?.urlTemplate ? ` (deep-link template configured)` : ""}.`;
3335
+ return `Custom EXTERNAL_ID field \u2014 opaque id${f.config?.url_template || f.config?.urlTemplate ? ` (deep-link template configured)` : ""}.`;
2736
3336
  default:
2737
3337
  return `Custom field of unrecognized type "${f.type}" \u2014 pass values as strings.`;
2738
3338
  }
@@ -2748,7 +3348,7 @@ var listMappableFields = {
2748
3348
  idempotentHint: true,
2749
3349
  openWorldHint: true
2750
3350
  },
2751
- description: "List every CRM field the agent can target when calling leadbay_import_leads or leadbay_import_and_qualify. Returns two arrays: `standard_fields` (Leadbay's built-in StandardCrmFieldType enum \u2014 LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, contact + location + sector fields) and `custom_fields` (this org's user-defined fields \u2014 id, name, type, and the literal `mapping_value` you pass in `mappings.fields`). For custom fields, `mapping_value` is the wire-format string `CUSTOM.<id>` \u2014 pass it verbatim.\n\nOptional `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. Saves a separate preview round-trip when the agent already has data in hand.\n\nWhen to use: before authoring an import mapping, especially when the CSV has columns that aren't obvious matches for standard fields. When NOT to use: when you already know the mapping \u2014 this call is cheap (~50ms with no for_records, ~5\u201310s with) but unnecessary if the agent has already cached the catalog within the same conversation.",
3351
+ description: leadbay_list_mappable_fields,
2752
3352
  inputSchema: {
2753
3353
  type: "object",
2754
3354
  properties: {
@@ -2826,7 +3426,10 @@ var listMappableFields = {
2826
3426
  }
2827
3427
  };
2828
3428
  if (Array.isArray(params.for_records) && params.for_records.length > 0) {
2829
- const notes = [];
3429
+ const notes = [
3430
+ "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.",
3431
+ "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."
3432
+ ];
2830
3433
  try {
2831
3434
  const sample = params.for_records.slice(0, PREVIEW_SAMPLE_CAP);
2832
3435
  const headerSet = /* @__PURE__ */ new Set();
@@ -2910,7 +3513,7 @@ var selectLeads = {
2910
3513
  idempotentHint: true,
2911
3514
  openWorldHint: true
2912
3515
  },
2913
- description: "Add leads to the user's transient selection (used by selection-scoped bulk operations). When to use: low-level. The user's selection is a per-token global state \u2014 be careful when invoking directly. When NOT to use: in normal flow \u2014 leadbay_enrich_titles wraps select \u2192 action \u2192 clear in one call with proper Mutex protection. Calling this directly without acquiring the selection lock can clobber concurrent composite calls.",
3516
+ description: leadbay_select_leads,
2914
3517
  optional: true,
2915
3518
  write: true,
2916
3519
  inputSchema: {
@@ -2954,7 +3557,7 @@ var deselectLeads = {
2954
3557
  idempotentHint: true,
2955
3558
  openWorldHint: true
2956
3559
  },
2957
- description: "Remove leads from the user's transient selection. When to use: when narrowing a previously-built selection without clearing it entirely. When NOT to use: in normal flow \u2014 leadbay_enrich_titles handles selection lifecycle.",
3560
+ description: leadbay_deselect_leads,
2958
3561
  optional: true,
2959
3562
  write: true,
2960
3563
  inputSchema: {
@@ -2987,7 +3590,7 @@ var clearSelection = {
2987
3590
  idempotentHint: true,
2988
3591
  openWorldHint: true
2989
3592
  },
2990
- description: "Clear the user's transient selection. When to use: cleanup after manual selection work, or recovery from a stuck composite. When NOT to use: in normal flow \u2014 composites clear in their own finally blocks.",
3593
+ description: leadbay_clear_selection,
2991
3594
  optional: true,
2992
3595
  write: true,
2993
3596
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
@@ -3007,7 +3610,7 @@ var setActiveLens = {
3007
3610
  idempotentHint: true,
3008
3611
  openWorldHint: true
3009
3612
  },
3010
- description: "Mark a lens as last-used. Subsequent /me reads return it as last_requested_lens, so all composite tools default to it. When to use: after the user explicitly switched contexts (e.g. created a new lens via leadbay_create_lens). When NOT to use: in normal flow \u2014 leadbay_pull_leads and leadbay_adjust_audience auto-set the right lens.",
3613
+ description: leadbay_set_active_lens,
3011
3614
  optional: true,
3012
3615
  write: true,
3013
3616
  inputSchema: {
@@ -3034,7 +3637,7 @@ var createLens = {
3034
3637
  idempotentHint: false,
3035
3638
  openWorldHint: true
3036
3639
  },
3037
- description: "Create a new user-level lens by cloning an existing lens's filter/scoring as the starting point. When to use: when adjust_audience determined the current lens cannot be edited (e.g. it's the org default). When NOT to use: to update an existing lens \u2014 use leadbay_update_lens or leadbay_update_lens_filter.",
3640
+ description: leadbay_create_lens,
3038
3641
  optional: true,
3039
3642
  write: true,
3040
3643
  inputSchema: {
@@ -3081,7 +3684,7 @@ var updateLens = {
3081
3684
  idempotentHint: true,
3082
3685
  openWorldHint: true
3083
3686
  },
3084
- description: "Update lens metadata (name, description, mode flags). Does NOT change the audience filter \u2014 use leadbay_update_lens_filter for that. When to use: rename a lens or toggle multi_product_mode/use_hq_only. When NOT to use: to change which leads the lens shows \u2014 that's a filter operation.",
3687
+ description: leadbay_update_lens,
3085
3688
  optional: true,
3086
3689
  write: true,
3087
3690
  inputSchema: {
@@ -3114,7 +3717,7 @@ var updateLensFilter = {
3114
3717
  idempotentHint: true,
3115
3718
  openWorldHint: true
3116
3719
  },
3117
- description: "Replace the audience filter (sectors, sizes, locations) on a lens. Body is the full Filter object \u2014 this is a REPLACE, not a merge. Returns 400 'default_lens' if applied to the org default lens (clone it first). When to use: low-level mutation when you've already prepared the merged filter. When NOT to use: from agent flow \u2014 use leadbay_adjust_audience, which handles draft-vs-direct routing, permission fallback, and the merge logic so unrelated criteria aren't dropped.",
3720
+ description: leadbay_update_lens_filter,
3118
3721
  optional: true,
3119
3722
  write: true,
3120
3723
  inputSchema: {
@@ -3160,7 +3763,7 @@ var createLensDraft = {
3160
3763
  idempotentHint: false,
3161
3764
  openWorldHint: true
3162
3765
  },
3163
- description: "Create (or fetch existing) draft of an org-level lens. Idempotent \u2014 same user calling twice returns the same draft. The returned lens has draft_of set to the original lens id. When to use: when a non-admin needs to modify an org-level lens \u2014 make a draft, edit the draft. When NOT to use: from agent flow \u2014 leadbay_adjust_audience handles the draft-routing transparently.",
3766
+ description: leadbay_create_lens_draft,
3164
3767
  optional: true,
3165
3768
  write: true,
3166
3769
  inputSchema: {
@@ -3184,7 +3787,7 @@ var promoteLens = {
3184
3787
  idempotentHint: false,
3185
3788
  openWorldHint: true
3186
3789
  },
3187
- description: "Promote a user-level lens (or draft) to org-level so all teammates see it. Admin-only. When to use: rare \u2014 when an admin user has built a lens (or refined a draft) and wants to share it org-wide. When NOT to use: as a non-admin (will fail with 403); for personal lens changes (those stay user-scoped).",
3790
+ description: leadbay_promote_lens,
3188
3791
  optional: true,
3189
3792
  write: true,
3190
3793
  inputSchema: {
@@ -3210,7 +3813,7 @@ var setUserPrompt = {
3210
3813
  idempotentHint: true,
3211
3814
  openWorldHint: true
3212
3815
  },
3213
- description: "Set the org's intelligence-refinement prompt \u2014 free-text instruction that steers Leadbay's lead recommendations beyond firmographics. Admin-only. Setting this clears any pending clarification and triggers a full intelligence regeneration (web search + high-reasoning). When to use: low-level. When NOT to use: from agent flow \u2014 use leadbay_refine_prompt, which polls for follow-up clarifications.",
3816
+ description: leadbay_set_user_prompt,
3214
3817
  optional: true,
3215
3818
  write: true,
3216
3819
  inputSchema: {
@@ -3255,7 +3858,7 @@ var clearUserPrompt = {
3255
3858
  idempotentHint: true,
3256
3859
  openWorldHint: true
3257
3860
  },
3258
- description: "Remove the org's intelligence-refinement prompt (revert to AI-only generation). Admin-only. Triggers full intelligence regeneration. When to use: when a refinement turned out to be the wrong direction. When NOT to use: to replace with a different prompt \u2014 just call leadbay_refine_prompt; that overwrites.",
3861
+ description: leadbay_clear_user_prompt,
3259
3862
  optional: true,
3260
3863
  write: true,
3261
3864
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
@@ -3277,7 +3880,7 @@ var pickClarification = {
3277
3880
  idempotentHint: false,
3278
3881
  openWorldHint: true
3279
3882
  },
3280
- description: "Answer the pending clarification question \u2014 either by picking one of the offered options (option_id) or by typing a free-text answer. The answer is stored as the new user_prompt and triggers regeneration. Admin-only. When to use: low-level. When NOT to use: from agent flow \u2014 use leadbay_answer_clarification.",
3883
+ description: leadbay_pick_clarification,
3281
3884
  optional: true,
3282
3885
  write: true,
3283
3886
  inputSchema: {
@@ -3324,7 +3927,7 @@ var dismissClarification = {
3324
3927
  idempotentHint: false,
3325
3928
  openWorldHint: true
3326
3929
  },
3327
- description: "Dismiss the pending clarification without answering. Leadbay proceeds with its best guess. Admin-only. When to use: when the user explicitly doesn't want to answer the disambiguation. When NOT to use: as a default \u2014 answering with even a free-text reason gives Leadbay better signal.",
3930
+ description: leadbay_dismiss_clarification,
3328
3931
  optional: true,
3329
3932
  write: true,
3330
3933
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
@@ -3357,7 +3960,7 @@ var setEpilogueStatus = {
3357
3960
  idempotentHint: true,
3358
3961
  openWorldHint: true
3359
3962
  },
3360
- description: "Bulk-set the outreach progress (epilogue) status across a set of leads. Status values: STILL_CHASING, COULD_NOT_REACH_STILL_TRYING, INTEREST_VALIDATED_OR_MEETING_PLANED ('meeting booked'), NOT_INTERESTED_LOST (short labels accepted; mapped to the EPILOGUE_* enum). Up to 1000 leads per call. When to use: low-level. When NOT to use: from agent flow \u2014 leadbay_report_outreach pairs this with a note + verification, which is what humans actually need to see in Leadbay.",
3963
+ description: leadbay_set_epilogue_status,
3361
3964
  optional: true,
3362
3965
  write: true,
3363
3966
  inputSchema: {
@@ -3404,7 +4007,7 @@ var removeEpilogue = {
3404
4007
  idempotentHint: true,
3405
4008
  openWorldHint: true
3406
4009
  },
3407
- description: "Bulk-clear the epilogue status from a set of leads. When to use: when an outreach action was logged in error and needs to be undone. When NOT to use: to change status \u2014 call leadbay_set_epilogue_status with the new status (it overwrites).",
4010
+ description: leadbay_remove_epilogue,
3408
4011
  optional: true,
3409
4012
  write: true,
3410
4013
  inputSchema: {
@@ -3437,7 +4040,7 @@ var previewBulkEnrichment = {
3437
4040
  idempotentHint: true,
3438
4041
  openWorldHint: true
3439
4042
  },
3440
- description: "Preview a bulk-enrichment cost given a set of job titles applied to the current selection. Returns {selected_leads, enriched_contacts, enrichable_contacts, title_suggestions, auto_included_titles, previously_enriched_titles}. previously_enriched_titles is a newer field (in prod soon) \u2014 when present, the agent can recommend repeating those titles for new leads. When to use: between selecting leads and launching, to know what the enrichment will cost. When NOT to use: from agent flow \u2014 leadbay_enrich_titles wraps preview + launch with the right safety checks.",
4043
+ description: leadbay_preview_bulk_enrichment,
3441
4044
  optional: true,
3442
4045
  write: true,
3443
4046
  inputSchema: {
@@ -3467,7 +4070,7 @@ var launchBulkEnrichment = {
3467
4070
  idempotentHint: true,
3468
4071
  openWorldHint: true
3469
4072
  },
3470
- description: "Launch a bulk-enrichment job against the current selection. The backend requires email=true OR phone=true (both can be true). Returns 204 with no body \u2014 there is no bulk_id and no per-job status endpoint. Track results by polling individual leads via leadbay_get_contacts after ~60s; contact.enrichment.done flips to true. When to use: low-level. When NOT to use: from agent flow \u2014 leadbay_enrich_titles handles selection lifecycle, preview, launch, and cleanup.",
4073
+ description: leadbay_launch_bulk_enrichment,
3471
4074
  optional: true,
3472
4075
  write: true,
3473
4076
  inputSchema: {
@@ -3520,6 +4123,98 @@ var launchBulkEnrichment = {
3520
4123
  }
3521
4124
  };
3522
4125
 
4126
+ // ../core/dist/tools/create-custom-field.js
4127
+ var createCustomField = {
4128
+ name: "leadbay_create_custom_field",
4129
+ annotations: {
4130
+ title: "Create CRM custom field",
4131
+ readOnlyHint: false,
4132
+ destructiveHint: false,
4133
+ idempotentHint: true,
4134
+ openWorldHint: true
4135
+ },
4136
+ description: leadbay_create_custom_field,
4137
+ write: true,
4138
+ version: "0.6.4",
4139
+ inputSchema: {
4140
+ type: "object",
4141
+ properties: {
4142
+ name: {
4143
+ type: "string",
4144
+ description: "User-visible custom field name, e.g. 'HubSpot Contact'."
4145
+ },
4146
+ type: {
4147
+ type: "string",
4148
+ description: "Custom field type: TEXT, NUMBER, PRICE, DATE, DATETIME, or EXTERNAL_ID. Defaults to TEXT."
4149
+ },
4150
+ config: {
4151
+ type: ["object", "null"],
4152
+ description: "Type-specific config. EXTERNAL_ID requires {url_template:'https://.../{value}'}; PRICE requires {currency:'USD'}; DATE/DATETIME may set {format}."
4153
+ },
4154
+ if_not_exists: {
4155
+ type: "boolean",
4156
+ description: "Default true. If a custom field with the same name already exists, return it instead of creating a duplicate."
4157
+ }
4158
+ },
4159
+ required: ["name"],
4160
+ additionalProperties: false
4161
+ },
4162
+ outputSchema: {
4163
+ type: "object",
4164
+ properties: {
4165
+ id: { type: "string" },
4166
+ name: { type: "string" },
4167
+ type: { type: "string" },
4168
+ config: { type: ["object", "null"] },
4169
+ mapping_value: {
4170
+ type: "string",
4171
+ description: "Wire mapping value to use in import mappings, e.g. CUSTOM.123."
4172
+ },
4173
+ existed: {
4174
+ type: "boolean",
4175
+ description: "True when if_not_exists reused an existing custom field."
4176
+ }
4177
+ },
4178
+ required: ["id", "name", "type", "mapping_value", "existed"]
4179
+ },
4180
+ execute: async (client, params) => {
4181
+ const name = params.name?.trim();
4182
+ if (!name) {
4183
+ 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");
4184
+ }
4185
+ const type = params.type ?? "TEXT";
4186
+ const config = params.config ?? null;
4187
+ if (type === "EXTERNAL_ID") {
4188
+ const urlTemplate = config?.url_template ?? config?.urlTemplate;
4189
+ if (!urlTemplate || !urlTemplate.includes("{value}")) {
4190
+ 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/<portal-id>/record/0-1/{value}.", "POST /crm/custom_fields");
4191
+ }
4192
+ }
4193
+ if (params.if_not_exists ?? true) {
4194
+ const existing = await client.request("GET", "/crm/custom_fields");
4195
+ const found = (existing ?? []).find((f) => f.name.toLowerCase() === name.toLowerCase());
4196
+ if (found) {
4197
+ return {
4198
+ ...found,
4199
+ mapping_value: `CUSTOM.${found.id}`,
4200
+ existed: true
4201
+ };
4202
+ }
4203
+ }
4204
+ const body = {
4205
+ name,
4206
+ type,
4207
+ ...config ? { config } : {}
4208
+ };
4209
+ const created = await client.request("POST", "/crm/custom_fields", body);
4210
+ return {
4211
+ ...created,
4212
+ mapping_value: `CUSTOM.${created.id}`,
4213
+ existed: false
4214
+ };
4215
+ }
4216
+ };
4217
+
3523
4218
  // ../core/dist/composite/research-company.js
3524
4219
  var researchCompany = {
3525
4220
  name: "leadbay_research_company",
@@ -3530,7 +4225,7 @@ var researchCompany = {
3530
4225
  idempotentHint: true,
3531
4226
  openWorldHint: true
3532
4227
  },
3533
- description: "Deep-dive research on a specific company by NAME (fuzzy match against the active lens's wishlist). When to use: when the user references a company by name and you don't yet have its lead_id. When NOT to use: when you already have the lead_id \u2014 use leadbay_research_lead directly (it bundles richer signals + better top-down ordering for the agent).",
4228
+ description: leadbay_research_company,
3534
4229
  inputSchema: {
3535
4230
  type: "object",
3536
4231
  properties: {
@@ -3614,7 +4309,7 @@ var prepareOutreach = {
3614
4309
  idempotentHint: true,
3615
4310
  openWorldHint: true
3616
4311
  },
3617
- description: "Prepare an outreach package for a single lead: recommended contact + enriched contact details + AI summary. When to use: when the agent is about to draft outreach for ONE specific lead and needs the contact's email/phone. When NOT to use: across many leads \u2014 use leadbay_enrich_titles for bulk; for general lead detail use leadbay_research_lead (richer signals); to actually log the outreach action use leadbay_report_outreach (requires verification).",
4312
+ description: leadbay_prepare_outreach,
3618
4313
  optional: true,
3619
4314
  inputSchema: {
3620
4315
  type: "object",
@@ -3729,7 +4424,7 @@ var pullLeads = {
3729
4424
  idempotentHint: true,
3730
4425
  openWorldHint: true
3731
4426
  },
3732
- description: "Pull up new leads from the user's last-active lens \u2014 the canonical 'show me today's prospects' tool. Leadbay works like an inbox: each time the user logs back in, a fresh batch is delivered, paced by how many leads they've actually acted on recently. Pulling more won't produce more; user outreach/skips/saves does. Each returned lead carries a one-line qualification_summary built from leadbay_ai_agent_responses, plus the rich tags / scores / recommended_contact_title / engagement counters / in-flight flags from the lead summary. Roughly the top 10 of the batch come pre-qualified (populated qualification_summary + ai_agent_lead_score); leads below the top ~10 carry only the basic firmographic `score` \u2014 not worse, just resource-saved by the system. Call leadbay_bulk_qualify_leads to deepen any of them on demand. When to use: as the agent's default opening move when the user wants to see leads, or as a daily check-in for what's new today. When NOT to use: when the user has named a specific lens \u2014 pass lensId to override the auto-resolution. Replaces the older leadbay_find_prospects (which is removed in v0.2.0).",
4427
+ description: leadbay_pull_leads,
3733
4428
  inputSchema: {
3734
4429
  type: "object",
3735
4430
  properties: {
@@ -3987,7 +4682,7 @@ var researchLead = {
3987
4682
  idempotentHint: true,
3988
4683
  openWorldHint: true
3989
4684
  },
3990
- description: "Tell me everything decision-relevant about a single lead. Bundles the lens-scoped lead profile, the AI qualification answers (the agent's knowledge-base food), the structured web-research signals (with hot flags + sources), the enriched contacts, and the recent notes/epilogue/prospecting activity in one call. Order is deliberate: qualification first, then signals, then firmographics, then contacts, then engagement. Scoring has two layers: the basic `score` (firmographic, always present, already decent) and the AI qualification layer (`ai_agent_lead_score` + per-question answers + web_fetch signals). The AI layer is pre-populated for roughly the top 10 of each daily batch, and on-demand (via leadbay_bulk_qualify_leads) for anything below that. Combine both layers when judging a lead. When to use: when picking up a single lead from leadbay_pull_leads to decide whether to act on it. When NOT to use: across many leads at once \u2014 that's leadbay_pull_leads' job. (This composite supersedes the lower-level leadbay_get_lead_profile in agent flow; the granular tool stays available for fine-grained access.)",
4685
+ description: leadbay_research_lead,
3991
4686
  inputSchema: {
3992
4687
  type: "object",
3993
4688
  properties: {
@@ -4272,7 +4967,7 @@ var recallOrderedTitles = {
4272
4967
  idempotentHint: true,
4273
4968
  openWorldHint: true
4274
4969
  },
4275
- description: "Show job titles the org has previously enriched, so the agent can repeat the same titles for new leads (or skip already-saturated ones). Two implementation paths: (1) PREFERRED: a selection-scoped preview call that reads previously_enriched_titles from the backend (newer prod field). (2) FALLBACK: live aggregation across each lead's enriched contacts. The composite picks transparently. When to use: before leadbay_enrich_titles, to plan which titles to order. When NOT to use: when you already know the exact titles you want to enrich.",
4970
+ description: leadbay_recall_ordered_titles,
4276
4971
  inputSchema: {
4277
4972
  type: "object",
4278
4973
  properties: {
@@ -4393,7 +5088,7 @@ var accountStatus = {
4393
5088
  idempotentHint: true,
4394
5089
  openWorldHint: true
4395
5090
  },
4396
- description: "Show the user's account state \u2014 admin rights, language, last-active lens, current quota usage across daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's intelligence is mid-regeneration. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption. When to use: at the start of a session to know what the agent can/can't do, or after a 429 to explain to the user which resource window was exhausted and when it resets. When NOT to use: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating.",
5091
+ description: leadbay_account_status,
4397
5092
  inputSchema: { type: "object", properties: {}, additionalProperties: false },
4398
5093
  outputSchema: {
4399
5094
  type: "object",
@@ -4490,7 +5185,7 @@ var bulkQualifyLeads = {
4490
5185
  idempotentHint: true,
4491
5186
  openWorldHint: true
4492
5187
  },
4493
- description: "Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch), polling until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null ai_agent_lead_score) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads. Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads below the top ~10 are NOT worse \u2014 the system is saving resources. This tool is how the agent spends more resources to go deeper on promising-looking leads the user hasn't had time to surface yet. When to use: when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty qualification_summary. When NOT to use: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).",
5188
+ description: leadbay_bulk_qualify_leads,
4494
5189
  inputSchema: {
4495
5190
  type: "object",
4496
5191
  properties: {
@@ -4514,6 +5209,10 @@ var bulkQualifyLeads = {
4514
5209
  total_budget_ms: {
4515
5210
  type: "number",
4516
5211
  description: `Total polling budget in ms (default ${DEFAULT_TOTAL_BUDGET_MS2})`
5212
+ },
5213
+ wait_for_completion: {
5214
+ type: "boolean",
5215
+ description: "When false, launch qualification and return `{status:'running', qualify_id}` immediately. Poll leadbay_qualify_status. Default is true for 0.6.x backwards compatibility."
4517
5216
  }
4518
5217
  },
4519
5218
  additionalProperties: false
@@ -4526,6 +5225,14 @@ var bulkQualifyLeads = {
4526
5225
  description: "Leads whose qualification finished within budget. Each entry: lead_id, qualification_summary{answered,total,avg_qualification_boost}, signals_count.",
4527
5226
  items: { type: "object" }
4528
5227
  },
5228
+ status: {
5229
+ type: "string",
5230
+ description: "`running` when wait_for_completion=false; absent on the legacy blocking result."
5231
+ },
5232
+ handle_id: { type: "string", description: "Alias of qualify_id for handle-oriented callers." },
5233
+ qualify_id: { type: "string", description: "UUIDv4 to poll via leadbay_qualify_status." },
5234
+ lead_ids: { type: "array", items: { type: "string" } },
5235
+ launched_count: { type: "number" },
4529
5236
  still_running: {
4530
5237
  type: "array",
4531
5238
  description: "Leads launched but whose qualification did not complete within budget. Re-poll via leadbay_qualify_status with the bulk_id (when present).",
@@ -4556,13 +5263,30 @@ var bulkQualifyLeads = {
4556
5263
  properties: { region: { type: "string" } }
4557
5264
  }
4558
5265
  },
4559
- required: ["qualified", "still_running", "failed", "quota_exceeded"]
5266
+ required: ["failed", "quota_exceeded"],
5267
+ anyOf: [
5268
+ { required: ["qualified", "still_running", "failed", "quota_exceeded"] },
5269
+ {
5270
+ required: [
5271
+ "status",
5272
+ "handle_id",
5273
+ "qualify_id",
5274
+ "lead_ids",
5275
+ "launched_count",
5276
+ "failed",
5277
+ "quota_exceeded",
5278
+ "lens_id",
5279
+ "_meta"
5280
+ ]
5281
+ }
5282
+ ]
4560
5283
  },
4561
5284
  execute: async (client, params, ctx) => {
4562
5285
  const wantCount = Math.min(params.count ?? DEFAULT_COUNT, MAX_COUNT);
4563
5286
  const perLeadBudget = params.per_lead_budget_ms ?? DEFAULT_PER_LEAD_BUDGET_MS;
4564
5287
  const totalBudget = params.total_budget_ms ?? DEFAULT_TOTAL_BUDGET_MS2;
4565
5288
  const totalDeadline = Date.now() + totalBudget;
5289
+ const waitForCompletion = params.wait_for_completion ?? true;
4566
5290
  let candidates;
4567
5291
  let exhausted = false;
4568
5292
  let totalUnqualifiedFound = 0;
@@ -4605,6 +5329,58 @@ var bulkQualifyLeads = {
4605
5329
  message: "No unqualified leads found in this lens \u2014 either all leads have been qualified, or the wishlist is still computing (check leadbay_account_status for computing_wishlist)."
4606
5330
  };
4607
5331
  }
5332
+ if (!waitForCompletion) {
5333
+ if (!ctx?.bulkTracker) {
5334
+ throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_bulk_qualify_leads wait_for_completion=false needs a BulkTracker so qualify_id survives restart.", "");
5335
+ }
5336
+ const reservation = await ctx.bulkTracker.findOrCreatePendingQualify({
5337
+ lead_ids: candidates,
5338
+ import_ids: [],
5339
+ lens_id: lensId,
5340
+ mapping_fingerprint: "bulk_qualify_leads",
5341
+ per_lead_budget_ms: perLeadBudget,
5342
+ total_budget_ms: totalBudget
5343
+ });
5344
+ const launched2 = [];
5345
+ const failed2 = [];
5346
+ let quotaExceeded2 = false;
5347
+ if (!reservation.reused) {
5348
+ for (const leadId of candidates) {
5349
+ if (quotaExceeded2)
5350
+ break;
5351
+ try {
5352
+ await client.requestVoid("POST", `/leads/${leadId}/web_fetch?force_fetch=false`);
5353
+ launched2.push(leadId);
5354
+ } catch (err) {
5355
+ if (err?.code === "QUOTA_EXCEEDED") {
5356
+ quotaExceeded2 = true;
5357
+ } else if (err?.code === "NOT_FOUND") {
5358
+ failed2.push({ lead_id: leadId, error: "lead not found" });
5359
+ } else {
5360
+ failed2.push({
5361
+ lead_id: leadId,
5362
+ error: err?.message ?? err?.code ?? "unknown"
5363
+ });
5364
+ }
5365
+ }
5366
+ }
5367
+ if (failed2.length === candidates.length || launched2.length > 0 || quotaExceeded2) {
5368
+ await ctx.bulkTracker.markLaunched(reservation.record.bulk_id);
5369
+ }
5370
+ }
5371
+ const out = {
5372
+ status: "running",
5373
+ handle_id: reservation.record.bulk_id,
5374
+ qualify_id: reservation.record.bulk_id,
5375
+ lead_ids: candidates,
5376
+ launched_count: reservation.reused ? reservation.record.lead_ids.length : launched2.length,
5377
+ failed: failed2,
5378
+ quota_exceeded: quotaExceeded2,
5379
+ lens_id: lensId,
5380
+ _meta: { region: client.region }
5381
+ };
5382
+ return out;
5383
+ }
4608
5384
  const launched = [];
4609
5385
  const failed = [];
4610
5386
  let quotaExceeded = false;
@@ -4648,64 +5424,382 @@ var bulkQualifyLeads = {
4648
5424
  resolve();
4649
5425
  }, { once: true });
4650
5426
  });
4651
- const results = await Promise.all(launched.map(async (leadId) => {
4652
- const leadDeadline = Math.min(Date.now() + perLeadBudget, totalDeadline);
4653
- let lastQual = null;
4654
- let lastWf = null;
4655
- while (Date.now() < leadDeadline) {
4656
- if (ctx?.signal?.aborted)
4657
- break;
4658
- try {
4659
- const [wfR, qualR] = await Promise.allSettled([
4660
- client.request("GET", `/leads/${leadId}/web_fetch`),
4661
- client.request("GET", `/leads/${leadId}/ai_agent_responses`)
4662
- ]);
4663
- if (wfR.status === "fulfilled")
4664
- lastWf = wfR.value;
4665
- if (qualR.status === "fulfilled")
4666
- lastQual = qualR.value;
4667
- const done = lastWf !== null && lastWf.in_progress !== true && Array.isArray(lastQual) && lastQual.length > 0 && lastQual.every((r) => r.score != null);
4668
- if (done) {
4669
- progressDone += 1;
4670
- ctx?.progress?.({
4671
- progress: progressDone,
4672
- total: progressTotal,
4673
- message: `Qualified lead ${leadId} (${progressDone}/${progressTotal})`
4674
- });
4675
- break;
4676
- }
4677
- } catch {
5427
+ const results = await Promise.all(launched.map(async (leadId) => {
5428
+ const leadDeadline = Math.min(Date.now() + perLeadBudget, totalDeadline);
5429
+ let lastQual = null;
5430
+ let lastWf = null;
5431
+ while (Date.now() < leadDeadline) {
5432
+ if (ctx?.signal?.aborted)
5433
+ break;
5434
+ try {
5435
+ const [wfR, qualR] = await Promise.allSettled([
5436
+ client.request("GET", `/leads/${leadId}/web_fetch`),
5437
+ client.request("GET", `/leads/${leadId}/ai_agent_responses`)
5438
+ ]);
5439
+ if (wfR.status === "fulfilled")
5440
+ lastWf = wfR.value;
5441
+ if (qualR.status === "fulfilled")
5442
+ lastQual = qualR.value;
5443
+ const done = lastWf !== null && lastWf.in_progress !== true && Array.isArray(lastQual) && lastQual.length > 0 && lastQual.every((r) => r.score != null);
5444
+ if (done) {
5445
+ progressDone += 1;
5446
+ ctx?.progress?.({
5447
+ progress: progressDone,
5448
+ total: progressTotal,
5449
+ message: `Qualified lead ${leadId} (${progressDone}/${progressTotal})`
5450
+ });
5451
+ break;
5452
+ }
5453
+ } catch {
5454
+ }
5455
+ if (ctx?.signal?.aborted)
5456
+ break;
5457
+ await sleepWithSignal(5e3);
5458
+ }
5459
+ const stillRunning = lastWf?.in_progress === true || !lastQual || lastQual.length === 0 || lastQual.some((r) => r.score == null);
5460
+ const responses = lastQual ?? [];
5461
+ const scores = responses.map((r) => r.score).filter((s) => s != null);
5462
+ const avg = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10 : null;
5463
+ return {
5464
+ lead_id: leadId,
5465
+ qualification_summary: responses.length > 0 ? {
5466
+ answered: responses.filter((r) => r.score != null).length,
5467
+ total: responses.length,
5468
+ avg_qualification_boost: avg
5469
+ } : null,
5470
+ signals_count: lastWf?.content ? Object.values(lastWf.content).reduce((acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0) : null,
5471
+ _stillRunning: stillRunning
5472
+ };
5473
+ }));
5474
+ const qualified = results.filter((r) => !r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
5475
+ const still_running = results.filter((r) => r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
5476
+ return {
5477
+ qualified,
5478
+ still_running,
5479
+ failed,
5480
+ quota_exceeded: quotaExceeded,
5481
+ exhausted,
5482
+ total_unqualified_found: totalUnqualifiedFound,
5483
+ lens_id: lensId,
5484
+ _meta: { region: client.region }
5485
+ };
5486
+ }
5487
+ };
5488
+
5489
+ // ../core/dist/composite/resolve-import-rows.js
5490
+ var SOCIAL_FIELDS = /* @__PURE__ */ new Set(["linkedin", "facebook", "instagram", "twitter", "tiktok"]);
5491
+ var RESOLVER_TARGETS = /* @__PURE__ */ new Set(["LEADBAY_ID", "CRM_ID", "LEAD_NAME", "LEAD_WEBSITE", "SIREN"]);
5492
+ var DEFAULT_CANDIDATE_PROFILE_LIMIT = 5;
5493
+ function coerceCell2(v) {
5494
+ if (v == null)
5495
+ return "";
5496
+ if (typeof v === "string")
5497
+ return v;
5498
+ if (typeof v === "number" || typeof v === "boolean")
5499
+ return String(v);
5500
+ return JSON.stringify(v) ?? "";
5501
+ }
5502
+ function compactMappings(mappings) {
5503
+ const out = {};
5504
+ for (const [k, v] of Object.entries(mappings)) {
5505
+ if (v)
5506
+ out[k] = v;
5507
+ }
5508
+ return out;
5509
+ }
5510
+ function payloadForRecord(row, mappings) {
5511
+ const payload = {};
5512
+ const socials = {};
5513
+ for (const [field, column] of Object.entries(mappings)) {
5514
+ const value = (row[column] ?? "").trim();
5515
+ if (!value)
5516
+ continue;
5517
+ if (SOCIAL_FIELDS.has(field)) {
5518
+ socials[field] = value;
5519
+ } else {
5520
+ payload[field] = value;
5521
+ }
5522
+ }
5523
+ if (Object.keys(socials).length > 0)
5524
+ payload.socials = socials;
5525
+ return payload;
5526
+ }
5527
+ function identityMappingsForImport(records, identityMappings) {
5528
+ const fields = {};
5529
+ if (records.some((r) => (r.LEADBAY_ID ?? "").trim() !== "")) {
5530
+ fields.LEADBAY_ID = "LEADBAY_ID";
5531
+ }
5532
+ const add = (field, target) => {
5533
+ const column = identityMappings[field];
5534
+ if (!column || fields[column])
5535
+ return;
5536
+ if (!records.some((r) => (r[column] ?? "").trim() !== ""))
5537
+ return;
5538
+ fields[column] = target;
5539
+ };
5540
+ add("leadbay_id", "LEADBAY_ID");
5541
+ add("crm_id", "CRM_ID");
5542
+ add("registry_number", "SIREN");
5543
+ add("name", "LEAD_NAME");
5544
+ add("website", "LEAD_WEBSITE");
5545
+ return { fields, statuses: {}, default_status: null };
5546
+ }
5547
+ function disambiguationPolicy() {
5548
+ return [
5549
+ "Use `matched` lead_id values directly; the tool already writes those into LEADBAY_ID.",
5550
+ "For `ambiguous` rows, do not choose a candidate from score alone. Score is a tied evidence-band, not a confidence percentage.",
5551
+ "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.",
5552
+ "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).",
5553
+ "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.",
5554
+ "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.",
5555
+ "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.",
5556
+ "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.",
5557
+ "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.",
5558
+ "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.",
5559
+ "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."
5560
+ ];
5561
+ }
5562
+ function mappingGuidance() {
5563
+ return [
5564
+ "Treat mappings_for_import as a safe identity starting point, not a complete CRM mapping.",
5565
+ "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.",
5566
+ "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.",
5567
+ "Always include LEADBAY_ID when records_for_import contains it; it makes deterministic matches import immediately.",
5568
+ "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.",
5569
+ "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.",
5570
+ "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.",
5571
+ "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.",
5572
+ "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.",
5573
+ "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.",
5574
+ "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.",
5575
+ "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.",
5576
+ "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.",
5577
+ "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.",
5578
+ "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."
5579
+ ];
5580
+ }
5581
+ async function hydrateCandidateProfiles(client, candidates, lensId, limit) {
5582
+ const selected = candidates.slice(0, Math.max(0, limit));
5583
+ const settled = await Promise.allSettled(selected.map((c) => client.request("GET", `/lenses/${lensId}/leads/${c.lead_id}`)));
5584
+ const out = [];
5585
+ settled.forEach((r, i) => {
5586
+ if (r.status !== "fulfilled")
5587
+ return;
5588
+ const lead = r.value;
5589
+ out.push({
5590
+ lead_id: selected[i].lead_id,
5591
+ name: lead.name ?? null,
5592
+ website: lead.website ?? null,
5593
+ location: lead.location ? {
5594
+ full: lead.location.full ?? null,
5595
+ city: lead.location.city ?? null,
5596
+ state: lead.location.state ?? null,
5597
+ country: lead.location.country ?? null
5598
+ } : null,
5599
+ phone_numbers: Array.isArray(lead.phone_numbers) ? lead.phone_numbers : [],
5600
+ description: typeof lead.description === "string" ? lead.description.slice(0, 400) : null
5601
+ });
5602
+ });
5603
+ return out;
5604
+ }
5605
+ function hasResolverTarget(mappings) {
5606
+ return Object.values(mappings.fields).some((v) => RESOLVER_TARGETS.has(v));
5607
+ }
5608
+ var resolveImportRows = {
5609
+ name: "leadbay_resolve_import_rows",
5610
+ annotations: {
5611
+ title: "Resolve import row identities",
5612
+ readOnlyHint: true,
5613
+ destructiveHint: false,
5614
+ idempotentHint: true,
5615
+ openWorldHint: true
5616
+ },
5617
+ description: leadbay_resolve_import_rows,
5618
+ write: false,
5619
+ version: "0.6.4",
5620
+ inputSchema: {
5621
+ type: "object",
5622
+ properties: {
5623
+ records: {
5624
+ type: "array",
5625
+ 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.",
5626
+ items: { type: "object" }
5627
+ },
5628
+ identity_mappings: {
5629
+ type: "object",
5630
+ 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.",
5631
+ properties: {
5632
+ leadbay_id: { type: "string" },
5633
+ crm_id: { type: "string" },
5634
+ name: { type: "string" },
5635
+ website: { type: "string" },
5636
+ phone: { type: "string" },
5637
+ email: { type: "string" },
5638
+ registry_number: { type: "string" },
5639
+ registry_type: { type: "string" },
5640
+ address: { type: "string" },
5641
+ city: { type: "string" },
5642
+ postcode: { type: "string" },
5643
+ country: { type: "string" },
5644
+ linkedin: { type: "string" },
5645
+ facebook: { type: "string" },
5646
+ instagram: { type: "string" },
5647
+ twitter: { type: "string" },
5648
+ tiktok: { type: "string" }
5649
+ },
5650
+ additionalProperties: false
5651
+ },
5652
+ include_candidate_profiles: {
5653
+ type: "boolean",
5654
+ 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."
5655
+ },
5656
+ candidate_profile_limit: {
5657
+ type: "number",
5658
+ description: `Maximum candidates to hydrate per ambiguous row when include_candidate_profiles=true (default ${DEFAULT_CANDIDATE_PROFILE_LIMIT}).`
5659
+ },
5660
+ lensId: {
5661
+ type: "number",
5662
+ description: "Lens ID used for candidate profile hydration. Defaults to the user's active lens."
5663
+ }
5664
+ },
5665
+ required: ["records"],
5666
+ additionalProperties: false
5667
+ },
5668
+ outputSchema: {
5669
+ type: "object",
5670
+ properties: {
5671
+ rows: {
5672
+ type: "array",
5673
+ description: "Per-input resolution result. Matched rows include lead_id; ambiguous rows include candidates; none/unidentifiable rows explain what extra signal would help.",
5674
+ items: { type: "object" }
5675
+ },
5676
+ records_for_import: {
5677
+ type: "array",
5678
+ 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.",
5679
+ items: { type: "object" }
5680
+ },
5681
+ mappings_for_import: {
5682
+ type: "object",
5683
+ description: "Safe identity-only mapping starter. The agent should review/extend this using mapping_guidance and leadbay_list_mappable_fields before importing."
5684
+ },
5685
+ identity_mappings_used: { type: "object" },
5686
+ mapping_guidance: {
5687
+ type: "array",
5688
+ description: "Instructions for building the final import mappings from the source columns.",
5689
+ items: { type: "string" }
5690
+ },
5691
+ disambiguation_policy: {
5692
+ type: "array",
5693
+ description: "Rules the agent should follow before writing LEADBAY_ID onto ambiguous rows.",
5694
+ items: { type: "string" }
5695
+ },
5696
+ summary: { type: "object" },
5697
+ next_action: { type: "string" },
5698
+ region: { type: "string" },
5699
+ _meta: { type: "object" }
5700
+ },
5701
+ required: [
5702
+ "rows",
5703
+ "records_for_import",
5704
+ "mappings_for_import",
5705
+ "identity_mappings_used",
5706
+ "mapping_guidance",
5707
+ "disambiguation_policy",
5708
+ "summary",
5709
+ "next_action",
5710
+ "region",
5711
+ "_meta"
5712
+ ]
5713
+ },
5714
+ execute: async (client, params) => {
5715
+ if (!Array.isArray(params.records) || params.records.length === 0) {
5716
+ 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");
5717
+ }
5718
+ const rows = params.records.map((rec, i) => {
5719
+ if (rec == null || typeof rec !== "object" || Array.isArray(rec)) {
5720
+ throw client.makeError("RESOLVE_IMPORT_INVALID_ROW", `records[${i}] must be a plain object`, "Pass each input row as { ColumnName: value, ... }.", "POST /leads/resolve");
5721
+ }
5722
+ const out = {};
5723
+ for (const [k, v] of Object.entries(rec))
5724
+ out[k] = coerceCell2(v);
5725
+ return out;
5726
+ });
5727
+ const identityMappings = compactMappings(params.identity_mappings ?? {});
5728
+ const allColumns = new Set(rows.flatMap((r) => Object.keys(r)));
5729
+ for (const [field, column] of Object.entries(identityMappings)) {
5730
+ if (!allColumns.has(column)) {
5731
+ 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");
5732
+ }
5733
+ }
5734
+ const outputs = [];
5735
+ const recordsForImport = [];
5736
+ const results = await Promise.all(rows.map(async (row) => {
5737
+ const payload = payloadForRecord(row, identityMappings);
5738
+ const result = await client.request("POST", "/leads/resolve", payload);
5739
+ return { payload, result };
5740
+ }));
5741
+ let matched = 0;
5742
+ let ambiguous = 0;
5743
+ let none = 0;
5744
+ let unidentifiable = 0;
5745
+ const hydrateProfiles = params.include_candidate_profiles === true;
5746
+ const candidateProfileLimit = params.candidate_profile_limit ?? DEFAULT_CANDIDATE_PROFILE_LIMIT;
5747
+ const hydrationLensId = hydrateProfiles ? params.lensId ?? await client.resolveDefaultLens() : null;
5748
+ for (let index = 0; index < results.length; index++) {
5749
+ const { payload, result } = results[index];
5750
+ const importRecord = { ...rows[index] };
5751
+ const rowOut = {
5752
+ index,
5753
+ type: result.type,
5754
+ resolver_payload: payload,
5755
+ import_record: importRecord
5756
+ };
5757
+ if (result.type === "matched") {
5758
+ matched++;
5759
+ importRecord.LEADBAY_ID = result.lead_id;
5760
+ rowOut.lead_id = result.lead_id;
5761
+ rowOut.matched_on = result.matched_on;
5762
+ } else if (result.type === "ambiguous") {
5763
+ ambiguous++;
5764
+ rowOut.candidates = result.candidates;
5765
+ if (hydrateProfiles && hydrationLensId !== null) {
5766
+ rowOut.candidate_profiles = await hydrateCandidateProfiles(client, result.candidates, hydrationLensId, candidateProfileLimit);
4678
5767
  }
4679
- if (ctx?.signal?.aborted)
4680
- break;
4681
- await sleepWithSignal(5e3);
5768
+ } else if (result.type === "none") {
5769
+ none++;
5770
+ rowOut.would_help = result.would_help;
5771
+ } else {
5772
+ unidentifiable++;
5773
+ rowOut.reason = result.reason;
4682
5774
  }
4683
- const stillRunning = lastWf?.in_progress === true || !lastQual || lastQual.length === 0 || lastQual.some((r) => r.score == null);
4684
- const responses = lastQual ?? [];
4685
- const scores = responses.map((r) => r.score).filter((s) => s != null);
4686
- const avg = scores.length > 0 ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length * 10) / 10 : null;
4687
- return {
4688
- lead_id: leadId,
4689
- qualification_summary: responses.length > 0 ? {
4690
- answered: responses.filter((r) => r.score != null).length,
4691
- total: responses.length,
4692
- avg_qualification_boost: avg
4693
- } : null,
4694
- signals_count: lastWf?.content ? Object.values(lastWf.content).reduce((acc, arr) => acc + (Array.isArray(arr) ? arr.length : 0), 0) : null,
4695
- _stillRunning: stillRunning
4696
- };
4697
- }));
4698
- const qualified = results.filter((r) => !r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
4699
- const still_running = results.filter((r) => r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
5775
+ recordsForImport.push(importRecord);
5776
+ outputs.push(rowOut);
5777
+ }
5778
+ const mappingsForImport = identityMappingsForImport(recordsForImport, identityMappings);
5779
+ const readyForImport = hasResolverTarget(mappingsForImport);
4700
5780
  return {
4701
- qualified,
4702
- still_running,
4703
- failed,
4704
- quota_exceeded: quotaExceeded,
4705
- exhausted,
4706
- total_unqualified_found: totalUnqualifiedFound,
4707
- lens_id: lensId,
4708
- _meta: { region: client.region }
5781
+ rows: outputs,
5782
+ records_for_import: recordsForImport,
5783
+ mappings_for_import: mappingsForImport,
5784
+ identity_mappings_used: identityMappings,
5785
+ mapping_guidance: mappingGuidance(),
5786
+ disambiguation_policy: disambiguationPolicy(),
5787
+ summary: {
5788
+ total: rows.length,
5789
+ matched,
5790
+ ambiguous,
5791
+ none,
5792
+ unidentifiable,
5793
+ ready_for_import: readyForImport
5794
+ },
5795
+ 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.",
5796
+ region: client.region,
5797
+ _meta: client.lastMeta ?? {
5798
+ region: client.region,
5799
+ endpoint: "POST /leads/resolve",
5800
+ latency_ms: null,
5801
+ retry_after: null
5802
+ }
4709
5803
  };
4710
5804
  }
4711
5805
  };
@@ -4799,28 +5893,7 @@ var importAndQualify = {
4799
5893
  idempotentHint: true,
4800
5894
  openWorldHint: true
4801
5895
  },
4802
- description: `Composite: import a list of leads (CSV-shaped records OR a list of domains), then trigger Leadbay's AI qualification (web research + per-question scoring) on every imported leadId, and return both the import outcome and the per-lead qualification answers \u2014 in one call. Honours a total wall-clock budget; when the budget is exhausted before all leads finish qualifying, returns a \`qualify_id\` UUID handle that survives MCP restart and can be passed to leadbay_qualify_status to retrieve the rest of the answers later.
4803
-
4804
- Inputs:
4805
- - \`domains\`: list of \`{domain, name?}\` (Mode A) \u2014 mutually exclusive with \`records\`.
4806
- - \`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.
4807
- - Budgets: \`total_budget_ms\` (default ${DEFAULT_TOTAL_BUDGET_MS3 / 6e4} min) caps the entire wall-clock; \`per_lead_budget_ms\` (default ${DEFAULT_PER_LEAD_BUDGET_MS2 / 1e3}s) caps each lead's individual qualification poll.
4808
-
4809
- Outputs include \`qualified[]\` (per-lead question answers), \`still_running[]\` (lead ids whose qualification exceeded the budget), \`not_imported[]\` (rows the wizard couldn't match), and \`qualify_id\` (the resumable handle when at least one lead is still running). Idempotent within a 5-min window: re-calling with the same records+mapping returns the same qualify_id (\`reused: true\`). The result has a \`kind\` discriminator (\`'result' | 'preview'\`); preview-mode (\`dry_run: 'preview'\`) returns mapping hints + custom-field candidates instead of importing. Pass \`dry_run: true\` for input-validation only (top-level \`dry_run: true\` appears in the result so the agent can distinguish from all-malformed input).
4810
-
4811
- When to use: the agent has a list of companies (domains, or CSV-shaped rows from the user's CRM) and wants Leadbay's full AI qualification \u2014 qualification answers, web-research signals \u2014 without orchestrating import + bulk_qualify_leads + lead_profile chains by hand.
4812
- When NOT to use: discovery (use leadbay_pull_leads); single-lead deep dive (use leadbay_research_lead); high-cadence or untrusted automation \u2014 this mutates user state by creating CRM-import rows and consumes ai_rescore + web_fetch quota.
4813
-
4814
- \u26A0\uFE0F MUTATES USER STATE. Each call:
4815
- - creates a CRM-imports row (visible in the web UI)
4816
- - touches onboarding state
4817
- - launches up to N\xD7ai_rescore + N\xD7web_fetch quota where N = imported lead count (unless \`skip_already_qualified: true\` (default) excludes already-scored leads)
4818
-
4819
- \u2139\uFE0F Monitor-tab membership: imported leads are NOT auto-promoted to the user's Monitor view. The lens-scoring rule decides \u2014 only leads that score above the lens threshold get \`in_monitor: true\` server-side. Lower-scoring imports stay invisible to the Monitor tab. Tell the user this if they ask 'where did my import go?' \u2014 answer is the CRM-imports list, not Monitor.
4820
-
4821
- \u2139\uFE0F In-lens-but-unscored leads: a lead may be admitted to the active lens (lens GET returns 200) but the lens-scoring job may not materialize for it (qualification never starts). The composite today surfaces hard-rejected leads (404 from lens GET) in \`not_in_lens[]\`, but in-lens-unscored leads currently sit in \`still_running[]\` \u2014 there's no per-lead 'scoring queued vs won't_compute' signal from the backend yet (tracked: leadbay/product#3571). If still_running stays non-empty for a lead more than 5 minutes after a successful import, suggest the user verify the lens covers that lead's profile (e.g., sector match) \u2014 qualify_status's poll won't ever produce answers.
4822
-
4823
- Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role; active billing.`,
5896
+ description: leadbay_import_and_qualify,
4824
5897
  write: true,
4825
5898
  version: "0.2.0",
4826
5899
  inputSchema: {
@@ -4861,7 +5934,7 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
4861
5934
  properties: {
4862
5935
  fields: {
4863
5936
  type: "object",
4864
- 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."
5937
+ 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 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."
4865
5938
  },
4866
5939
  custom_fields: {
4867
5940
  type: "object",
@@ -4887,6 +5960,10 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
4887
5960
  type: "number",
4888
5961
  description: `Per-phase budget for the import wizard (default ${DEFAULT_PER_PHASE_BUDGET_MS2}); mirrors leadbay_import_leads.`
4889
5962
  },
5963
+ wait_for_completion: {
5964
+ type: "boolean",
5965
+ description: "When false, enqueue the import phase and return `{kind:'result', status:'running', handle_id}` immediately. Poll leadbay_import_status. Default is true for 0.6.x backwards compatibility."
5966
+ },
4890
5967
  lensId: {
4891
5968
  type: "number",
4892
5969
  description: "Lens id (escape hatch \u2014 defaults to active)."
@@ -4909,6 +5986,14 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
4909
5986
  type: "string",
4910
5987
  description: "'result' (full flow) or 'preview' (dry_run='preview' mapping diagnostics)."
4911
5988
  },
5989
+ status: {
5990
+ type: "string",
5991
+ description: "`running` when wait_for_completion=false."
5992
+ },
5993
+ handle_id: {
5994
+ type: "string",
5995
+ description: "Import handle to pass to leadbay_import_status when wait_for_completion=false."
5996
+ },
4912
5997
  // preview-shape keys
4913
5998
  mapping_hints: {
4914
5999
  type: "array",
@@ -5015,19 +6100,76 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
5015
6100
  if (!ctx?.bulkTracker) {
5016
6101
  throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_and_qualify needs a BulkTracker (qualify_id persistence). Upgrade to @leadbay/mcp \u22650.5.0 or set LEADBAY_BULK_STORE_ALLOW_MEMORY=1.", "");
5017
6102
  }
6103
+ if (params.wait_for_completion === false) {
6104
+ const queued = await importLeads.execute(client, {
6105
+ domains: params.domains,
6106
+ records: params.records,
6107
+ mappings: params.mappings,
6108
+ per_phase_budget_ms: perPhaseBudget,
6109
+ total_budget_ms: totalBudget,
6110
+ ...params.dry_run === true ? { dry_run: true } : {},
6111
+ wait_for_completion: false
6112
+ }, ctx);
6113
+ if (!isImportLeadsRunningResult(queued)) {
6114
+ return {
6115
+ kind: "result",
6116
+ ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
6117
+ qualify_id: null,
6118
+ import_ids: queued.importIds,
6119
+ imported: queued.leads.map((l) => ({
6120
+ leadId: l.leadId,
6121
+ ...l.domain ? { domain: l.domain } : {},
6122
+ name: l.name ?? null,
6123
+ ...l.rowId ? { rowId: l.rowId } : {}
6124
+ })),
6125
+ not_imported: queued.not_imported.map(toNotImportedEntry),
6126
+ qualified: [],
6127
+ still_running: [],
6128
+ failed: [],
6129
+ quota_exceeded: false,
6130
+ skipped_already_qualified: [],
6131
+ not_in_lens: [],
6132
+ region: client.region,
6133
+ _meta: queued._meta
6134
+ };
6135
+ }
6136
+ return {
6137
+ kind: "result",
6138
+ status: "running",
6139
+ handle_id: queued.handle_id,
6140
+ ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
6141
+ qualify_id: null,
6142
+ import_ids: queued.importIds,
6143
+ imported: [],
6144
+ not_imported: [],
6145
+ qualified: [],
6146
+ still_running: [],
6147
+ failed: [],
6148
+ quota_exceeded: false,
6149
+ skipped_already_qualified: [],
6150
+ not_in_lens: [],
6151
+ region: client.region,
6152
+ _meta: queued._meta
6153
+ };
6154
+ }
5018
6155
  ctx?.progress?.({
5019
6156
  progress: 1,
5020
6157
  total: 3,
5021
6158
  message: "Importing leads (phase 1/3 \u2014 preprocess + commit)"
5022
6159
  });
5023
- const importResult = await importLeads.execute(client, {
6160
+ const importResultRaw = await importLeads.execute(client, {
5024
6161
  domains: params.domains,
5025
6162
  records: params.records,
5026
6163
  mappings: params.mappings,
5027
6164
  per_phase_budget_ms: perPhaseBudget,
5028
6165
  total_budget_ms: totalBudget,
5029
- ...params.dry_run === true ? { dry_run: true } : {}
6166
+ ...params.dry_run === true ? { dry_run: true } : {},
6167
+ wait_for_completion: true
5030
6168
  }, ctx);
6169
+ if (isImportLeadsRunningResult(importResultRaw)) {
6170
+ throw client.makeError("IMPORT_ASYNC_UNEXPECTED", "Import returned an async handle while import_and_qualify was waiting for completion", "Retry with wait_for_completion=false and poll leadbay_import_status, or retry the blocking call.", "POST /imports");
6171
+ }
6172
+ const importResult = importResultRaw;
5031
6173
  if (importResult.cancelled) {
5032
6174
  return {
5033
6175
  kind: "result",
@@ -5320,7 +6462,7 @@ import { mkdir as mkdirAsync, lstat, open as fsOpen, readFile, rename, stat, unl
5320
6462
  import { constants as fsConstants } from "fs";
5321
6463
  import { dirname, resolve as resolvePath } from "path";
5322
6464
  import { homedir, platform } from "os";
5323
- import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
6465
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
5324
6466
  var DEFAULT_IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
5325
6467
  var TTL_MS = 30 * 24 * 60 * 60 * 1e3;
5326
6468
  var UUIDV4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -5335,7 +6477,7 @@ function computeIdempotencyKey(args) {
5335
6477
  args.phone ? "p1" : "p0",
5336
6478
  `l${args.lens_id}`
5337
6479
  ];
5338
- return createHash2("sha256").update(parts.join("|")).digest("hex");
6480
+ return createHash3("sha256").update(parts.join("|")).digest("hex");
5339
6481
  }
5340
6482
  function computeQualifyIdempotencyKey(args) {
5341
6483
  const parts = [
@@ -5345,7 +6487,16 @@ function computeQualifyIdempotencyKey(args) {
5345
6487
  `l${args.lens_id}`,
5346
6488
  args.mapping_fingerprint
5347
6489
  ];
5348
- return createHash2("sha256").update(parts.join("|")).digest("hex");
6490
+ return createHash3("sha256").update(parts.join("|")).digest("hex");
6491
+ }
6492
+ function computeImportIdempotencyKey(args) {
6493
+ const parts = [
6494
+ "import",
6495
+ args.mode,
6496
+ args.dry_run ? "dry1" : "dry0",
6497
+ args.import_fingerprint
6498
+ ];
6499
+ return createHash3("sha256").update(parts.join("|")).digest("hex");
5349
6500
  }
5350
6501
  function normalizeLaunchInputs(args) {
5351
6502
  return {
@@ -5483,7 +6634,7 @@ var LocalBulkStore = class {
5483
6634
  throw new Error("missing launched_at");
5484
6635
  if (!Array.isArray(r.lead_ids) || !r.lead_ids.every((x) => typeof x === "string"))
5485
6636
  throw new Error("invalid lead_ids");
5486
- if (r.status !== "pending" && r.status !== "launched" && r.status !== "failed")
6637
+ if (r.status !== "pending" && r.status !== "launched" && r.status !== "complete" && r.status !== "failed" && r.status !== "cancelled")
5487
6638
  throw new Error("invalid status");
5488
6639
  if (typeof r.idempotency_key !== "string")
5489
6640
  throw new Error("invalid idempotency_key");
@@ -5510,6 +6661,52 @@ var LocalBulkStore = class {
5510
6661
  out.total_budget_ms = r.total_budget_ms;
5511
6662
  return out;
5512
6663
  }
6664
+ if (kind === "import") {
6665
+ if (!Array.isArray(r.import_ids) || !r.import_ids.every((x) => typeof x === "string"))
6666
+ throw new Error("invalid import_ids");
6667
+ if (r.mode !== "domains" && r.mode !== "records")
6668
+ throw new Error("invalid mode");
6669
+ if (typeof r.dry_run !== "boolean")
6670
+ throw new Error("invalid dry_run");
6671
+ if (typeof r.records_total !== "number")
6672
+ throw new Error("invalid records_total");
6673
+ const out = {
6674
+ kind: "import",
6675
+ bulk_id: r.bulk_id,
6676
+ launched_at: r.launched_at,
6677
+ lead_ids: r.lead_ids,
6678
+ import_ids: r.import_ids,
6679
+ mode: r.mode,
6680
+ dry_run: r.dry_run,
6681
+ records_total: r.records_total,
6682
+ status: r.status,
6683
+ idempotency_key: r.idempotency_key,
6684
+ durability: this.backend
6685
+ };
6686
+ if (r.result && typeof r.result === "object") {
6687
+ const result = r.result;
6688
+ if (Array.isArray(result.leads) && Array.isArray(result.not_imported) && Array.isArray(result.importIds) && result.importIds.every((x) => typeof x === "string")) {
6689
+ out.result = {
6690
+ leads: result.leads,
6691
+ not_imported: result.not_imported,
6692
+ importIds: result.importIds
6693
+ };
6694
+ }
6695
+ }
6696
+ if (r.progress && typeof r.progress === "object") {
6697
+ const p = r.progress;
6698
+ if (typeof p.phase === "string" && typeof p.records_processed === "number" && typeof p.records_total === "number") {
6699
+ out.progress = {
6700
+ phase: p.phase,
6701
+ records_processed: p.records_processed,
6702
+ records_total: p.records_total
6703
+ };
6704
+ }
6705
+ }
6706
+ if (typeof r.error === "string")
6707
+ out.error = r.error;
6708
+ return out;
6709
+ }
5513
6710
  if (kind === "enrich") {
5514
6711
  if (!Array.isArray(r.titles) || !r.titles.every((x) => typeof x === "string"))
5515
6712
  throw new Error("invalid titles");
@@ -5673,10 +6870,119 @@ var LocalBulkStore = class {
5673
6870
  return { record, reused: false };
5674
6871
  });
5675
6872
  }
6873
+ async findOrCreatePendingImport(args) {
6874
+ const idempotency_key = computeImportIdempotencyKey({
6875
+ import_fingerprint: args.import_fingerprint,
6876
+ mode: args.mode,
6877
+ dry_run: args.dry_run
6878
+ });
6879
+ const window = args.idempotency_window_ms ?? DEFAULT_IDEMPOTENCY_WINDOW_MS;
6880
+ return this.mutex.run(async () => {
6881
+ const all = this.prune(await this.readAll());
6882
+ const nowMs = this.now();
6883
+ const existing = all.find((r) => r.kind === "import" && r.idempotency_key === idempotency_key && r.status !== "failed" && r.status !== "cancelled" && nowMs - Date.parse(r.launched_at) < window);
6884
+ if (existing) {
6885
+ this.logger?.info?.(`bulk.reused kind=import bulk_id=${existing.bulk_id} seconds_since_original=${Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)}`);
6886
+ return {
6887
+ record: existing,
6888
+ reused: true,
6889
+ seconds_since_original: Math.round((nowMs - Date.parse(existing.launched_at)) / 1e3)
6890
+ };
6891
+ }
6892
+ const record = {
6893
+ kind: "import",
6894
+ bulk_id: randomUUID2(),
6895
+ launched_at: new Date(nowMs).toISOString(),
6896
+ lead_ids: [],
6897
+ import_ids: [],
6898
+ mode: args.mode,
6899
+ dry_run: args.dry_run,
6900
+ records_total: args.records_total,
6901
+ progress: {
6902
+ phase: "queued",
6903
+ records_processed: 0,
6904
+ records_total: args.records_total
6905
+ },
6906
+ status: "pending",
6907
+ idempotency_key,
6908
+ durability: this.backend
6909
+ };
6910
+ all.push(record);
6911
+ await this.writeAll(all);
6912
+ this.logger?.info?.(`bulk.registered kind=import bulk_id=${record.bulk_id} mode=${record.mode} records_total=${record.records_total} durability=${record.durability}`);
6913
+ return { record, reused: false };
6914
+ });
6915
+ }
5676
6916
  async getQualify(bulk_id) {
5677
6917
  const r = await this.get(bulk_id);
5678
6918
  return r && r.kind === "qualify" ? r : void 0;
5679
6919
  }
6920
+ async getImport(bulk_id) {
6921
+ const r = await this.get(bulk_id);
6922
+ return r && r.kind === "import" ? r : void 0;
6923
+ }
6924
+ async setImportIds(bulk_id, import_ids) {
6925
+ return this.mutex.run(async () => {
6926
+ const all = this.prune(await this.readAll());
6927
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
6928
+ if (idx < 0)
6929
+ throw new Error(`import bulk_id not found: ${bulk_id}`);
6930
+ const record = all[idx];
6931
+ all[idx] = {
6932
+ ...record,
6933
+ import_ids: [...new Set(import_ids)].sort(),
6934
+ status: record.status === "pending" ? "launched" : record.status
6935
+ };
6936
+ await this.writeAll(all);
6937
+ return all[idx];
6938
+ });
6939
+ }
6940
+ async setImportProgress(bulk_id, progress) {
6941
+ return this.mutex.run(async () => {
6942
+ const all = this.prune(await this.readAll());
6943
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
6944
+ if (idx < 0)
6945
+ throw new Error(`import bulk_id not found: ${bulk_id}`);
6946
+ const record = all[idx];
6947
+ all[idx] = { ...record, progress };
6948
+ await this.writeAll(all);
6949
+ return all[idx];
6950
+ });
6951
+ }
6952
+ async markImportComplete(bulk_id, result) {
6953
+ return this.mutex.run(async () => {
6954
+ const all = this.prune(await this.readAll());
6955
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
6956
+ if (idx < 0)
6957
+ throw new Error(`import bulk_id not found: ${bulk_id}`);
6958
+ const record = all[idx];
6959
+ all[idx] = {
6960
+ ...record,
6961
+ import_ids: [...new Set(result.importIds)].sort(),
6962
+ result,
6963
+ progress: {
6964
+ phase: "complete",
6965
+ records_processed: record.records_total,
6966
+ records_total: record.records_total
6967
+ },
6968
+ status: "complete"
6969
+ };
6970
+ await this.writeAll(all);
6971
+ this.logger?.info?.(`bulk.import_complete bulk_id=${bulk_id}`);
6972
+ return all[idx];
6973
+ });
6974
+ }
6975
+ async markImportFailed(bulk_id, error) {
6976
+ return this.mutex.run(async () => {
6977
+ const all = this.prune(await this.readAll());
6978
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id && r.kind === "import");
6979
+ if (idx < 0)
6980
+ return;
6981
+ all[idx] = { ...all[idx], status: "failed", error };
6982
+ await this.writeAll(all);
6983
+ this.logger?.info?.(`bulk.import_failed bulk_id=${bulk_id}`);
6984
+ });
6985
+ }
5680
6986
  async markLaunched(bulk_id) {
5681
6987
  return this.mutex.run(async () => {
5682
6988
  const all = this.prune(await this.readAll());
@@ -5770,6 +7076,200 @@ async function createDefaultBulkStore(opts = {}) {
5770
7076
  }
5771
7077
  }
5772
7078
 
7079
+ // ../core/dist/composite/import-status.js
7080
+ function summarizeImports(imports, dryRun) {
7081
+ let recordsTotal = 0;
7082
+ let recordsProcessed = 0;
7083
+ let hasPreprocess = false;
7084
+ let hasProcess = false;
7085
+ let hasFailed = false;
7086
+ for (const imp of imports) {
7087
+ recordsTotal += Number(imp.total_records ?? 0);
7088
+ recordsProcessed += Number(imp.imported_records ?? 0);
7089
+ if (!imp.pre_processing?.finished) {
7090
+ hasPreprocess = true;
7091
+ continue;
7092
+ }
7093
+ if (imp.pre_processing?.error) {
7094
+ hasFailed = true;
7095
+ continue;
7096
+ }
7097
+ if (dryRun === true) {
7098
+ continue;
7099
+ }
7100
+ if (!imp.processing?.finished) {
7101
+ if (dryRun === false || imp.processing != null)
7102
+ hasProcess = true;
7103
+ continue;
7104
+ }
7105
+ if (imp.processing?.error) {
7106
+ hasFailed = true;
7107
+ }
7108
+ }
7109
+ const phase = hasFailed ? "failed" : hasPreprocess ? "preprocess" : hasProcess ? "process" : imports.length > 0 ? "complete" : "queued";
7110
+ return {
7111
+ phase,
7112
+ records_processed: recordsProcessed,
7113
+ records_total: recordsTotal
7114
+ };
7115
+ }
7116
+ var importStatus = {
7117
+ name: "leadbay_import_status",
7118
+ annotations: {
7119
+ title: "Poll import status",
7120
+ readOnlyHint: true,
7121
+ destructiveHint: false,
7122
+ idempotentHint: true,
7123
+ openWorldHint: true
7124
+ },
7125
+ description: leadbay_import_status,
7126
+ inputSchema: {
7127
+ type: "object",
7128
+ properties: {
7129
+ handle_id: {
7130
+ type: "string",
7131
+ description: "UUIDv4 handle returned by leadbay_import_leads when wait_for_completion=false."
7132
+ },
7133
+ importIds: {
7134
+ type: "array",
7135
+ description: "Legacy backend file-import ids to inspect directly.",
7136
+ items: { type: "string" }
7137
+ }
7138
+ },
7139
+ additionalProperties: false
7140
+ },
7141
+ outputSchema: {
7142
+ type: "object",
7143
+ properties: {
7144
+ status: { type: "string", description: "running, complete, or failed." },
7145
+ handle_id: { type: "string" },
7146
+ importIds: { type: "array", items: { type: "string" } },
7147
+ progress: { type: "object" },
7148
+ result: {
7149
+ type: "object",
7150
+ description: "Final import result when the handle has completed in this MCP instance."
7151
+ },
7152
+ error: { type: "string" },
7153
+ region: { type: "string" },
7154
+ _meta: { type: "object" }
7155
+ },
7156
+ required: ["status", "importIds", "progress", "region", "_meta"]
7157
+ },
7158
+ execute: async (client, params, ctx) => {
7159
+ let handleId = params.handle_id;
7160
+ let importIds = params.importIds ?? [];
7161
+ let handleDryRun;
7162
+ if (handleId) {
7163
+ if (!isValidBulkId(handleId)) {
7164
+ throw client.makeError("BULK_INVALID_ID", "handle_id is not a valid UUIDv4", "Pass the handle_id returned by leadbay_import_leads verbatim.", "");
7165
+ }
7166
+ if (!ctx?.bulkTracker) {
7167
+ throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_status needs a BulkTracker to resolve handle_id. Pass importIds[] directly as a fallback.", "");
7168
+ }
7169
+ const record = await ctx.bulkTracker.getImport(handleId);
7170
+ if (!record) {
7171
+ const any = await ctx.bulkTracker.get(handleId);
7172
+ if (any && any.kind !== "import") {
7173
+ throw client.makeError("BULK_WRONG_KIND", "This handle was not created by leadbay_import_leads", "Use leadbay_qualify_status for qualify ids or leadbay_bulk_enrich_status for enrich ids.", "");
7174
+ }
7175
+ throw client.makeError("BULK_NOT_FOUND", "No import record for that handle_id", "It may have expired (30-day TTL) or the MCP process was restarted without persistence.", "");
7176
+ }
7177
+ importIds = record.import_ids;
7178
+ handleDryRun = record.dry_run;
7179
+ if (record.status === "complete" && record.result) {
7180
+ return {
7181
+ status: "complete",
7182
+ handle_id: handleId,
7183
+ importIds,
7184
+ progress: record.progress ?? {
7185
+ phase: "complete",
7186
+ records_processed: record.records_total,
7187
+ records_total: record.records_total
7188
+ },
7189
+ result: record.result,
7190
+ region: client.region,
7191
+ _meta: client.lastMeta ?? {
7192
+ region: client.region,
7193
+ endpoint: "bulk-store",
7194
+ latency_ms: null,
7195
+ retry_after: null
7196
+ }
7197
+ };
7198
+ }
7199
+ if (record.status === "failed") {
7200
+ return {
7201
+ status: "failed",
7202
+ handle_id: handleId,
7203
+ importIds,
7204
+ progress: record.progress ?? {
7205
+ phase: "failed",
7206
+ records_processed: 0,
7207
+ records_total: record.records_total
7208
+ },
7209
+ error: record.error ?? "import failed",
7210
+ region: client.region,
7211
+ _meta: client.lastMeta ?? {
7212
+ region: client.region,
7213
+ endpoint: "bulk-store",
7214
+ latency_ms: null,
7215
+ retry_after: null
7216
+ }
7217
+ };
7218
+ }
7219
+ if (importIds.length === 0) {
7220
+ return {
7221
+ status: "running",
7222
+ handle_id: handleId,
7223
+ importIds,
7224
+ progress: record.progress ?? {
7225
+ phase: "queued",
7226
+ records_processed: 0,
7227
+ records_total: record.records_total
7228
+ },
7229
+ region: client.region,
7230
+ _meta: client.lastMeta ?? {
7231
+ region: client.region,
7232
+ endpoint: "bulk-store",
7233
+ latency_ms: null,
7234
+ retry_after: null
7235
+ }
7236
+ };
7237
+ }
7238
+ }
7239
+ if (importIds.length === 0) {
7240
+ throw client.makeError("IMPORT_STATUS_INPUT_REQUIRED", "Pass either handle_id or importIds[]", "Call leadbay_import_leads with wait_for_completion=false first, then pass its handle_id.", "");
7241
+ }
7242
+ const imports = await Promise.all(importIds.map((id) => client.request("GET", `/imports/${id}`)));
7243
+ const progress = summarizeImports(imports, handleDryRun);
7244
+ const failed = imports.find((i) => i.pre_processing?.error || i.processing?.error);
7245
+ const complete = imports.every((i) => {
7246
+ if (i.pre_processing?.error || i.processing?.error)
7247
+ return false;
7248
+ if (handleDryRun === true)
7249
+ return Boolean(i.pre_processing?.finished);
7250
+ if (handleDryRun === false)
7251
+ return Boolean(i.processing?.finished);
7252
+ return Boolean(i.processing?.finished || i.pre_processing?.finished && !i.processing);
7253
+ });
7254
+ return {
7255
+ status: failed ? "failed" : complete ? "complete" : "running",
7256
+ ...handleId ? { handle_id: handleId } : {},
7257
+ importIds,
7258
+ progress,
7259
+ ...failed ? {
7260
+ error: failed.pre_processing?.error ?? failed.processing?.error ?? "import failed"
7261
+ } : {},
7262
+ region: client.region,
7263
+ _meta: client.lastMeta ?? {
7264
+ region: client.region,
7265
+ endpoint: "GET /imports/<id>",
7266
+ latency_ms: null,
7267
+ retry_after: null
7268
+ }
7269
+ };
7270
+ }
7271
+ };
7272
+
5773
7273
  // ../core/dist/composite/qualify-status.js
5774
7274
  var qualifyStatus = {
5775
7275
  name: "leadbay_qualify_status",
@@ -5780,7 +7280,7 @@ var qualifyStatus = {
5780
7280
  idempotentHint: true,
5781
7281
  openWorldHint: true
5782
7282
  },
5783
- description: "Retrieve the current state of an import_and_qualify launch by `qualify_id`. Returns the same `qualified[]` / `still_running[]` shape as the original composite, refreshed against the backend at call time. The handle is persisted to ~/.leadbay/bulks.json with a 30-day TTL and survives MCP restart.\n\nWhen to use: after leadbay_import_and_qualify returned a qualify_id with non-empty `still_running[]`, call this tool a few minutes later (or hours) to retrieve the now-completed qualifications without re-running the import or re-spending qualify quota.\nWhen NOT to use: as a substitute for leadbay_research_lead \u2014 that's a deeper per-lead profile and includes contacts. This tool is purely the qualification answers + signals_count.",
7283
+ description: leadbay_qualify_status,
5784
7284
  inputSchema: {
5785
7285
  type: "object",
5786
7286
  properties: {
@@ -5865,7 +7365,8 @@ var qualifyStatus = {
5865
7365
  if (!record) {
5866
7366
  const any = await ctx.bulkTracker.get(params.qualify_id);
5867
7367
  if (any && any.kind !== "qualify") {
5868
- throw client.makeError("BULK_WRONG_KIND", "This bulk_id was created by leadbay_enrich_titles, not leadbay_import_and_qualify", "Call leadbay_bulk_enrich_status with this id instead.", "");
7368
+ const hint = any.kind === "import" ? "Call leadbay_import_status with this id instead." : "Call leadbay_bulk_enrich_status with this id instead.";
7369
+ throw client.makeError("BULK_WRONG_KIND", `This bulk_id was created by ${any.kind}, not leadbay_import_and_qualify`, hint, "");
5869
7370
  }
5870
7371
  throw client.makeError("BULK_NOT_FOUND", "No qualify record for that qualify_id", "It may have expired (30-day TTL) or the MCP process was restarted without persistence. Re-launch via leadbay_import_and_qualify.", "");
5871
7372
  }
@@ -5927,7 +7428,7 @@ var qualifyStatus = {
5927
7428
  const out = {
5928
7429
  qualify_id: record.bulk_id,
5929
7430
  launched_at: record.launched_at,
5930
- status: record.status,
7431
+ status: record.status === "complete" ? "launched" : record.status,
5931
7432
  import_ids: record.import_ids,
5932
7433
  lens_id: record.lens_id,
5933
7434
  lead_ids: record.lead_ids,
@@ -5967,7 +7468,7 @@ var enrichTitles = {
5967
7468
  idempotentHint: true,
5968
7469
  openWorldHint: true
5969
7470
  },
5970
- description: "Order contact enrichments by job title across many leads. Contacts are NOT returned by default with a lead (Leadbay keeps enrichment out-of-band to control cost); the agent requests them on demand via this tool when it's ready to actually reach out. Two modes: (A) NO titles param \u2014 returns the available titles + Leadbay's title_suggestions + auto_included_titles + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) titles given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns {status:'quota_exceeded'} cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error. When to use: as the agent's go-to enrichment entry point, immediately before proposing outreach. When NOT to use: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular). Speculatively, before the user has committed to outreaching \u2014 enrichment spends credits.",
7471
+ description: leadbay_enrich_titles,
5971
7472
  inputSchema: {
5972
7473
  type: "object",
5973
7474
  properties: {
@@ -6328,7 +7829,7 @@ var bulkEnrichStatus = {
6328
7829
  idempotentHint: true,
6329
7830
  openWorldHint: true
6330
7831
  },
6331
- description: "Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Returns the bulk_id, progress per lead (done/total enrichable contacts), and overall progress. When include_contacts=true (opt-in), includes each contact's email/phone/job_title/enrichment.done. When to use: poll this after leadbay_enrich_titles returns a bulk_id. Default include_contacts=false for cheap status polls; set include_contacts=true once all_done flips for the final read. When NOT to use: as a substitute for leadbay_research_lead \u2014 that already includes enriched contacts for a single lead.",
7832
+ description: leadbay_bulk_enrich_status,
6332
7833
  inputSchema: {
6333
7834
  type: "object",
6334
7835
  properties: {
@@ -6429,12 +7930,12 @@ var bulkEnrichStatus = {
6429
7930
  hint: "The record may have aged out (30-day TTL) or the MCP process was restarted without persistence. Launch a new enrichment via leadbay_enrich_titles."
6430
7931
  };
6431
7932
  }
6432
- if (record.kind === "qualify") {
7933
+ if (record.kind !== "enrich") {
6433
7934
  return {
6434
7935
  error: true,
6435
7936
  code: "BULK_WRONG_KIND",
6436
- message: "This bulk_id was created by leadbay_import_and_qualify, not leadbay_enrich_titles.",
6437
- hint: "Call leadbay_qualify_status with this id instead.",
7937
+ message: `This bulk_id was created by ${record.kind === "qualify" ? "leadbay_import_and_qualify" : "leadbay_import_leads"}, not leadbay_enrich_titles.`,
7938
+ hint: record.kind === "qualify" ? "Call leadbay_qualify_status with this id instead." : "Call leadbay_import_status with this id instead.",
6438
7939
  bulk_id: record.bulk_id
6439
7940
  };
6440
7941
  }
@@ -6648,7 +8149,7 @@ var adjustAudience = {
6648
8149
  idempotentHint: true,
6649
8150
  openWorldHint: true
6650
8151
  },
6651
- description: "Restrict (or expand) the lens audience by sector / size. Free-text sectors are auto-resolved against the sector taxonomy; ambiguous matches are surfaced to the agent rather than guessed silently. Permission routing is hidden: the default lens auto-clones to a new user lens; an org-level lens defaults to a per-user draft (admins can override with save_for_org:true). Filter MERGES with existing criteria (unrelated criteria are not dropped). When to use: when the user wants to see different kinds of leads (sector / size / etc.). When NOT to use: to refine BEYOND firmographics \u2014 that's leadbay_refine_prompt.",
8152
+ description: leadbay_adjust_audience,
6652
8153
  inputSchema: {
6653
8154
  type: "object",
6654
8155
  properties: {
@@ -6833,7 +8334,7 @@ var refinePrompt = {
6833
8334
  idempotentHint: false,
6834
8335
  openWorldHint: true
6835
8336
  },
6836
- description: "Refine the kind of leads Leadbay surfaces, beyond firmographics. Free-text instruction (e.g. 'focus on hospitals running their own IT'). Sets the org's user_prompt; if the new prompt produces ambiguous criteria, Leadbay raises a clarification question, which this composite polls for and surfaces. Admin-only on the backend (will return 403 for non-admins). When to use: when audience filters (leadbay_adjust_audience) aren't enough. When NOT to use: to answer a pending clarification \u2014 that's leadbay_answer_clarification.",
8337
+ description: leadbay_refine_prompt,
6837
8338
  inputSchema: {
6838
8339
  type: "object",
6839
8340
  properties: {
@@ -7021,7 +8522,7 @@ var answerClarification = {
7021
8522
  idempotentHint: false,
7022
8523
  openWorldHint: true
7023
8524
  },
7024
- description: "Answer the pending clarification question Leadbay raised after a refine_prompt. The answer is stored as the new user_prompt and triggers regeneration. Pass option_id (preferred \u2014 pick from the offered options) or text_answer (free-text). Admin-only. When to use: after leadbay_refine_prompt returns status='clarification_pending'. When NOT to use: to set a brand-new prompt \u2014 use leadbay_refine_prompt.",
8525
+ description: leadbay_answer_clarification,
7025
8526
  inputSchema: {
7026
8527
  type: "object",
7027
8528
  properties: {
@@ -7112,7 +8613,7 @@ var reportOutreach = {
7112
8613
  idempotentHint: false,
7113
8614
  openWorldHint: true
7114
8615
  },
7115
- description: "Log an outreach action (email, call, message, meeting) on a lead so the human team using Leadbay sees the progress in their UI. Writes a NOTE on the lead and (optionally) sets an EPILOGUE status (still chasing, meeting booked, etc.). VERIFICATION REQUIRED: every call must include verification={source: 'gmail_message_id'|'calendar_event_id'|'user_confirmed', ref: '<id-or-confirmation>'} to prevent hallucinated outreach poisoning the pipeline. The verification is appended to the note body. Bulk variant: pass lead_ids=[uuid,...] instead of lead_id (epilogue is bulk-native; notes fan out per-lead). When to use: AFTER actually emailing/calling/meeting/messaging a contact, OR after a substantive decision the user wants logged (skip, save, hand off). When NOT to use: BEFORE doing the outreach (use dry_run:true to validate args first); without verification (call will be rejected); from a flow where the user did not consent to having actions logged automatically.",
8616
+ description: leadbay_report_outreach,
7116
8617
  optional: true,
7117
8618
  write: true,
7118
8619
  inputSchema: {
@@ -7421,7 +8922,8 @@ var granularWriteTools = [
7421
8922
  setEpilogueStatus,
7422
8923
  removeEpilogue,
7423
8924
  previewBulkEnrichment,
7424
- launchBulkEnrichment
8925
+ launchBulkEnrichment,
8926
+ createCustomField
7425
8927
  ];
7426
8928
  var granularTools = [
7427
8929
  login,
@@ -7438,6 +8940,8 @@ var compositeReadTools = [
7438
8940
  accountStatus,
7439
8941
  bulkEnrichStatus,
7440
8942
  qualifyStatus,
8943
+ importStatus,
8944
+ resolveImportRows,
7441
8945
  // listMappableFields is granular-shaped but the import composites depend on
7442
8946
  // it for discoverability; expose it always-on so agents can find custom fields
7443
8947
  // without needing LEADBAY_MCP_ADVANCED=1.
@@ -7454,7 +8958,13 @@ var compositeWriteTools = [
7454
8958
  answerClarification,
7455
8959
  reportOutreach,
7456
8960
  importLeads,
7457
- importAndQualify
8961
+ importAndQualify,
8962
+ // createCustomField is granular-shaped but file-import prompts depend on it
8963
+ // to preserve source-system links without requiring advanced-tool exposure.
8964
+ createCustomField,
8965
+ // addNote is granular-shaped but file-import prompts depend on it to preserve
8966
+ // meaningful source-file notes after imports return lead ids.
8967
+ addNote
7458
8968
  ];
7459
8969
  var compositeTools = [
7460
8970
  ...compositeReadTools,
@@ -7511,6 +9021,7 @@ export {
7511
9021
  removeEpilogue,
7512
9022
  previewBulkEnrichment,
7513
9023
  launchBulkEnrichment,
9024
+ createCustomField,
7514
9025
  researchCompany,
7515
9026
  prepareOutreach,
7516
9027
  pullLeads,
@@ -7518,11 +9029,13 @@ export {
7518
9029
  recallOrderedTitles,
7519
9030
  accountStatus,
7520
9031
  bulkQualifyLeads,
9032
+ resolveImportRows,
7521
9033
  importAndQualify,
7522
9034
  isValidBulkId,
7523
9035
  LocalBulkStore,
7524
9036
  InMemoryBulkStore,
7525
9037
  createDefaultBulkStore,
9038
+ importStatus,
7526
9039
  qualifyStatus,
7527
9040
  enrichTitles,
7528
9041
  bulkEnrichStatus,