@leadbay/mcp 0.31.0 → 0.32.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.
package/dist/bin.js CHANGED
@@ -5693,7 +5693,7 @@ var init_notifications = __esm({
5693
5693
  });
5694
5694
 
5695
5695
  // ../core/dist/tool-descriptions.generated.js
5696
- var leadbay_account_history, leadbay_account_status, leadbay_acknowledge_notification, leadbay_add_contact, leadbay_add_leads_to_campaign, leadbay_add_note, leadbay_adjust_audience, leadbay_agent_memory_capture, leadbay_agent_memory_recall, leadbay_agent_memory_review, leadbay_answer_clarification, leadbay_artifact_kit, leadbay_bulk_enrich_status, leadbay_bulk_qualify_leads, leadbay_campaign_call_sheet, leadbay_campaign_progression, leadbay_clear_selection, leadbay_clear_user_prompt, leadbay_create_campaign, leadbay_create_custom_field, leadbay_create_lens, leadbay_create_lens_draft, leadbay_create_topup_link, leadbay_delete_custom_field, leadbay_deselect_leads, leadbay_discover_leads, leadbay_dislike_lead, leadbay_dismiss_clarification, leadbay_enrich_contacts, leadbay_enrich_titles, leadbay_extend_lens, leadbay_followups_map, leadbay_get_clarification, leadbay_get_contacts, leadbay_get_enrichment_job_titles, leadbay_get_epilogue_responses, leadbay_get_lead_activities, leadbay_get_lead_custom_fields, leadbay_get_lead_notes, leadbay_get_lead_profile, leadbay_get_lens_filter, leadbay_get_lens_scoring, leadbay_get_prospecting_actions, leadbay_get_qualification_questions, leadbay_get_quota, leadbay_get_selection_ids, leadbay_get_taste_profile, leadbay_get_user_prompt, leadbay_get_web_fetch, leadbay_getting_started, leadbay_import_and_qualify, leadbay_import_leads, leadbay_import_status, leadbay_launch_bulk_enrichment, leadbay_like_lead, leadbay_list_campaigns, leadbay_list_lenses, leadbay_list_locations, leadbay_list_mappable_fields, leadbay_list_sectors, leadbay_login, leadbay_my_lenses, leadbay_new_lens, leadbay_open_billing_portal, leadbay_pick_clarification, leadbay_pin_contact, leadbay_prepare_outreach, leadbay_preview_bulk_enrichment, leadbay_promote_lens, leadbay_pull_followups, leadbay_pull_leads, leadbay_qualify_lead, leadbay_qualify_status, leadbay_recall_ordered_titles, leadbay_refine_prompt, leadbay_remove_contact, leadbay_remove_epilogue, leadbay_remove_leads_from_campaign, leadbay_remove_pushback, leadbay_report_friction, leadbay_report_outreach, leadbay_research_lead_by_id, leadbay_research_lead_by_name_fuzzy, leadbay_resolve_import_rows, leadbay_scan_portfolio_signals, leadbay_seed_candidates, leadbay_select_leads, leadbay_send_feedback, leadbay_set_active_lens, leadbay_set_epilogue_status, leadbay_set_pushback, leadbay_set_qualification_questions, leadbay_set_telemetry, leadbay_set_user_prompt, leadbay_team_activity, leadbay_tour_plan, leadbay_unpin_contact, leadbay_update_contact, leadbay_update_custom_field, leadbay_update_lens, leadbay_update_lens_filter;
5696
+ var leadbay_account_history, leadbay_account_status, leadbay_acknowledge_notification, leadbay_add_contact, leadbay_add_leads_to_campaign, leadbay_add_note, leadbay_adjust_audience, leadbay_agent_memory_capture, leadbay_agent_memory_recall, leadbay_agent_memory_review, leadbay_answer_clarification, leadbay_artifact_kit, leadbay_bulk_enrich_status, leadbay_bulk_qualify_leads, leadbay_campaign_call_sheet, leadbay_campaign_progression, leadbay_clear_selection, leadbay_clear_user_prompt, leadbay_create_campaign, leadbay_create_custom_field, leadbay_create_lens, leadbay_create_lens_draft, leadbay_create_topup_link, leadbay_delete_custom_field, leadbay_deselect_leads, leadbay_discover_leads, leadbay_dislike_lead, leadbay_dismiss_clarification, leadbay_enrich_contacts, leadbay_enrich_titles, leadbay_extend_lens, leadbay_followups_map, leadbay_get_clarification, leadbay_get_contacts, leadbay_get_enrichment_job_titles, leadbay_get_epilogue_responses, leadbay_get_lead_activities, leadbay_get_lead_custom_fields, leadbay_get_lead_notes, leadbay_get_lead_profile, leadbay_get_lens_filter, leadbay_get_lens_scoring, leadbay_get_prospecting_actions, leadbay_get_qualification_questions, leadbay_get_quota, leadbay_get_selection_ids, leadbay_get_taste_profile, leadbay_get_user_prompt, leadbay_get_web_fetch, leadbay_getting_started, leadbay_import_and_qualify, leadbay_import_leads, leadbay_import_status, leadbay_launch_bulk_enrichment, leadbay_like_lead, leadbay_list_campaigns, leadbay_list_lenses, leadbay_list_locations, leadbay_list_mappable_fields, leadbay_list_sectors, leadbay_login, leadbay_my_lenses, leadbay_new_lens, leadbay_open_billing_portal, leadbay_pick_clarification, leadbay_pin_contact, leadbay_prepare_outreach, leadbay_preview_bulk_enrichment, leadbay_promote_lens, leadbay_pull_followups, leadbay_pull_leads, leadbay_qualify_lead, leadbay_qualify_status, leadbay_recall_ordered_titles, leadbay_refine_prompt, leadbay_remove_contact, leadbay_remove_epilogue, leadbay_remove_leads_from_campaign, leadbay_remove_pushback, leadbay_report_friction, leadbay_report_outreach, leadbay_research_lead_by_id, leadbay_research_lead_by_name_fuzzy, leadbay_resolve_import_rows, leadbay_scan_portfolio_signals, leadbay_seed_candidates, leadbay_select_leads, leadbay_send_feedback, leadbay_set_active_lens, leadbay_set_epilogue_status, leadbay_set_lead_status, leadbay_set_pushback, leadbay_set_qualification_questions, leadbay_set_telemetry, leadbay_set_user_prompt, leadbay_team_activity, leadbay_tour_plan, leadbay_unpin_contact, leadbay_update_contact, leadbay_update_custom_field, leadbay_update_lens, leadbay_update_lens_filter;
5697
5697
  var init_tool_descriptions_generated = __esm({
5698
5698
  "../core/dist/tool-descriptions.generated.js"() {
5699
5699
  "use strict";
@@ -6247,7 +6247,7 @@ The model \u2014 two layers. Primitives: \`lb.field\` (value + API-populated opt
6247
6247
 
6248
6248
  Canonical uses: a cold-call sheet (\`lb.callList\` + per-row \`lb.outreach\`/\`lb.leadHistory\`); a manager dashboard (\`lb.teamActivity\` \u2192 leaderboard table + Chart.js trend); a live enrichment view (\`lb.enrichment\` \u2192 progress + refresh). Live auto-poll is host-dependent \u2014 always wire a Refresh.
6249
6249
 
6250
- Write-call footguns (in the guide, repeated because they bite): for \`leadbay_report_outreach\` (status/disposition) the \`args\` MUST include \`verification:{source:"user_confirmed",ref:"\u2026"}\` AND \`_triggered_by:"<the user's request>"\`, or the call is rejected. \`leadbay_add_leads_to_campaign\` needs \`_triggered_by\` too. \`leadbay_add_note\`/\`leadbay_like_lead\`/\`leadbay_dislike_lead\` need only their own args. Snoozing (pushback) and standalone status are advanced-gated \u2014 not callable from a default artifact; use \`report_outreach\`'s \`epilogue_status\` for outcomes.
6250
+ Write-call footguns (in the guide, repeated because they bite): for \`leadbay_report_outreach\` (status/disposition) the \`args\` MUST include \`verification:{source:"user_confirmed",ref:"\u2026"}\` AND \`_triggered_by:"<the user's request>"\`, or the call is rejected. \`leadbay_add_leads_to_campaign\` needs \`_triggered_by\` too. \`leadbay_add_note\`/\`leadbay_like_lead\`/\`leadbay_dislike_lead\` need only their own args. Snoozing (pushback) is advanced-gated \u2014 not callable from a default artifact. Org CRM status IS available: \`lb.leadStatus()\` gives the Wanted/Won/Lost/Unwanted picker field and \`lb.setStatus()\` the write (\`leadbay_set_lead_status\`), with the partial-write check baked in. Keep it distinct from \`report_outreach\`'s \`epilogue_status\`, which records how one outreach ATTEMPT went.
6251
6251
 
6252
6252
  WHEN TO USE: the user asks for a clickable / interactive artifact, dashboard, or call sheet that DOES things (not just displays data).
6253
6253
 
@@ -6832,8 +6832,10 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
6832
6832
 
6833
6833
  \`queued\` \u2192 \u2705 "Queued <N> extra leads on lens <id>. Pull in ~30s." Do NOT
6834
6834
  list \`accepted_seeds\`; they're internal.
6835
- \`quota_exceeded\` \u2192 render three options via your host's choice widget (\`ask_user_input_v0\` or \`AskUserQuestion\`) (smaller
6836
- count / wait until reset / upgrade).
6835
+ \`no_candidates\` \u2192 \u26D4 surface \`reason.message\`, name the criteria, offer
6836
+ \`leadbay_adjust_audience\`. Never re-call this tool on that lens.
6837
+ \`quota_exceeded\` \u2192 three options via the host's choice widget
6838
+ (smaller count / wait for reset / upgrade).
6837
6839
  \`refresh_in_progress\` \u2192 "lens is filling, retry in a minute".
6838
6840
  \`no_valid_seeds\` \u2192 silently re-call \`leadbay_seed_candidates\`, retry once.
6839
6841
 
@@ -6853,6 +6855,9 @@ Queue an additive extra-refill on a lens \u2014 more leads on the same criteria,
6853
6855
  - \`status: "quota_exceeded"\` \u2014 daily LENS_EXTRA_REFILL hit. Response carries \`quota: {used_today, resets_at}\` + a \`message\` to surface. **Render three options via your host's choice widget (\`ask_user_input_v0\` or \`AskUserQuestion\`)**: (1) smaller \`extra_count\`, (2) wait until \`resets_at\`, (3) upgrade plan (TIER1=150, TIER2=1000). Do NOT silently retry.
6854
6856
  - \`status: "refresh_in_progress"\` \u2014 a refresh or extra-refill is already running. Tell the user to wait and call \`leadbay_pull_leads\` in ~30s.
6855
6857
  - \`status: "no_valid_seeds"\` \u2014 seeds went stale. Silently re-call \`leadbay_seed_candidates\` and retry once; only surface to the user if the second attempt also fails.
6858
+ - \`status: "no_candidates"\` \u2014 **the refill was NOT queued.** The lens's candidate pool is empty, so a refill would report success, consume no quota and deliver nothing. \`reason\` carries the same \`{code, message, retryable, criteria?, narrow_locations?}\` shape \`leadbay_pull_leads\` returns in \`empty_reason\`, with \`retryable: false\`. **Stop. Do not re-call this tool on this lens** \u2014 the outcome cannot change until the audience changes. Surface \`reason.message\`, name the criteria in play, and offer \`leadbay_adjust_audience\` (or \`leadbay_pull_followups\` when \`reason.code\` is \`no_new_leads\` and the lens already holds leads).
6859
+
6860
+ **Extendability is checked before the write.** Every response carries \`available_count\` \u2014 how many leads a refill could still draw, read from the lens's own pool. \`0\` means the call was refused (\`no_candidates\`); \`null\` means the pool could not be read and the refill was queued anyway. An empty lens is NOT evidence of a broken refill: it is usually a lens that never had candidates. Reach for \`leadbay_adjust_audience\`, not another \`leadbay_extend_lens\`.
6856
6861
 
6857
6862
  WHEN TO USE: when the user has a bigger appetite than the daily lens fill delivers \u2014 they want MORE of the same kind of leads, on demand. Canonical phrasings: "I want more leads on this lens", "extend the lens", "give me a bigger batch today". The \`leadbay_extend_my_lens\` prompt is the user-facing entry point that orchestrates the whole flow.
6858
6863
 
@@ -6893,6 +6898,8 @@ Pick the row matching the response \`status\`. Seed-picking is internal; do NOT
6893
6898
  | \`quota_exceeded\` | "Upgrade plan for a higher daily limit" | (no call \u2014 direct user to contact account manager / sales) |
6894
6899
  | \`refresh_in_progress\` | "Lens is already filling \u2014 pull leads in a minute" | \`leadbay_pull_leads()\` (after a short wait) |
6895
6900
  | \`no_valid_seeds\` | (silent retry \u2014 re-call \`leadbay_seed_candidates\` then \`leadbay_extend_lens\`) | internal \u2014 only surface if the second attempt also fails |
6901
+ | \`no_candidates\` | "Widen the audience \u2014 this lens has nothing left to add" | \`leadbay_adjust_audience()\` \u2014 never \`leadbay_extend_lens\` again |
6902
+ | \`no_candidates\` (\`reason.code: no_new_leads\`) | "Work the leads already in the lens" | \`leadbay_pull_followups()\` |
6896
6903
 
6897
6904
  If nothing matches cleanly, default to "pull leads now to see what's queued" \u2014 never invent a tool that doesn't exist.
6898
6905
  `;
@@ -7098,9 +7105,31 @@ WHEN NOT TO USE: to answer the question \u2014 use leadbay_answer_clarification.
7098
7105
  `;
7099
7106
  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'\`.
7100
7107
 
7101
- 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.
7108
+ WHEN TO USE: to check enrichment status (\`contact.enrichment\`) on individual leads after a bulk enrichment was launched, or to find the \`contact_id\` needed by leadbay_enrich_contacts.
7102
7109
 
7103
7110
  WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id, which already includes enriched contacts in its return.
7111
+
7112
+ ## Reading \`contact.enrichment\`
7113
+
7114
+ \`enrichment\` is the per-contact reveal record. Four states. Read \`done\` and \`credits_used\` **together** \u2014 neither is a verdict on its own.
7115
+
7116
+ | \`enrichment\` | \`done\` | \`credits_used\` | Meaning | What to do |
7117
+ |---|---|---|---|---|
7118
+ | missing / \`null\` | \u2014 | \u2014 | Never requested. | Enrichable \u2014 launch it. |
7119
+ | present | \`false\` | any | Reservation in flight. | Poll. Do NOT re-launch. |
7120
+ | present | \`true\` | \`0\` | Settled, found nothing. | **Terminal. Stop.** |
7121
+ | present | \`true\` | \`> 0\` | Resolved. | The channel is on the org-source twin, not here \u2014 see below. |
7122
+
7123
+ **\`done: true\` with \`credits_used: 0\` is terminal for that contact.** The enrichment completed and the provider returned nothing. Do not re-attempt it on a later run \u2014 the answer will not change. Roughly 29% of all enrichments land here, so it is an ordinary outcome, not an anomaly. Tell the user plainly that there is no reachable contact rather than reporting it as still pending. The one exception: a launch that errored in this same session settles its reservation as a zero-credit failure, so retry that one once, then treat it as terminal.
7124
+
7125
+ Two ways to misread the pair:
7126
+
7127
+ - **\`credits_used: 0\` alone means nothing.** An in-flight reservation also reports \`0\` (with \`done: false\`). Gate every read of \`credits_used\` on \`done: true\`.
7128
+ - **An absent \`credits_used\` is unknown, not zero.** The field is optional. When it is missing you cannot conclude terminal-empty \u2014 only an explicit \`0\` alongside \`done: true\` means "we looked and found nothing".
7129
+
7130
+ A missing or \`null\` \`enrichment\` is not the same as \`done: false\`. It means the contact was never requested, so \`enrichment?.done\` reading falsy does NOT mean a reveal is running. Treat missing/\`null\` as enrichable and \`done: false\` as in-flight.
7131
+
7132
+ **Where a resolved channel lands.** A \`source:'paid'\` contact keeps its \`enrichment\` record but never carries the revealed \`email\` / \`phone_number\` itself \u2014 the reveal materializes a \`source:'org'\` twin of the same person, and the channel is on that entry. So a paid contact reading \`done: true, credits_used: 1\` with a null \`email\` is RESOLVED, not failed: find the org-source entry with the same name and read the channel there. Judge success by the channel you can actually see across both entries, never by \`enrichment\` on the paid record alone.
7104
7133
  `;
7105
7134
  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.
7106
7135
 
@@ -7415,7 +7444,7 @@ WHEN NOT TO USE: discovery (use leadbay_pull_leads); single-lead deep dive (use
7415
7444
 
7416
7445
  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.
7417
7446
 
7418
- \`not_imported\` rows with \`reason:"uncrawled"\` are **pending a background crawl**, NOT failures: Leadbay just hasn't matched/crawled that domain yet and will add the lead asynchronously (the label doesn't verify the URL resolves \u2014 don't call the site bad, but don't certify it valid either). Surface them as pending; the leads populate in the user's Leadbay account as the crawl completes (no tool here fetches them on demand \u2014 \`leadbay_import_status\` returns status/progress only, and \`leadbay_pull_leads\` reads the active lens's wishlist so an imported lead outside that lens may not appear). To pull those specific companies back through the MCP, re-run the import later. A large \`uncrawled\` share on a fresh list is normal.
7447
+ \`not_imported\` rows with \`reason:"uncrawled"\` are **pending a background crawl**, NOT failures: Leadbay just hasn't matched/crawled that domain yet and will add the lead asynchronously (the label doesn't verify the URL resolves \u2014 don't call the site bad, but don't certify it valid either). Surface them as pending; the leads populate in the user's Leadbay account as the crawl completes (no tool here fetches them on demand \u2014 \`leadbay_import_status\` reports the rows the wizard has already placed, not leads a later crawl adds, and \`leadbay_pull_leads\` reads the active lens's wishlist so an imported lead outside that lens may not appear). To pull those specific companies back through the MCP, re-run the import later. A large \`uncrawled\` share on a fresh list is normal.
7419
7448
 
7420
7449
  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\`.
7421
7450
 
@@ -7440,7 +7469,8 @@ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before yo
7440
7469
  **Header \u2014 single line, choose by status:**
7441
7470
 
7442
7471
  - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
7443
- - Running: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
7472
+ - Running, \`handle_id\` present: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
7473
+ - Running with \`timed_out:true\` (blocking call ran out of poll budget): the import is FINE and still running server-side \u2014 never render this as an error or a failure. \`"\u23F3 Import still running (the backend is slow today) \u2014 I'll check back."\` Then call \`leadbay_import_status({importIds})\`, do NOT re-run leadbay_import_leads. If \`rows_pending_upload\` is present, add \`"\u26A0 K rows weren't submitted \u2014 re-import just those."\`
7444
7474
  - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
7445
7475
 
7446
7476
  Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
@@ -7489,7 +7519,9 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
7489
7519
 
7490
7520
  | Observation | Suggest | Calls |
7491
7521
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
7492
- | Status: running | "Check progress" | leadbay_import_status(handle_id) |
7522
+ | Status: running, \`handle_id\` present | "Check progress" | leadbay_import_status(handle_id) |
7523
+ | Status: running with \`timed_out:true\` | "Check progress" \u2014 NOT "retry the import" | leadbay_import_status(importIds, dry_run if the result carried it) after ~30s; \`result.leads\` carries the leadIds once complete |
7524
+ | \`rows_pending_upload\` present | "Import the rows that never got submitted" | leadbay_import_leads (that subset only) |
7493
7525
  | Status: complete, imports succeeded | "Run AI qualification on the imported leads" | leadbay_bulk_qualify_leads([leadIds]) \u2014 or use leadbay_import_and_qualify next time |
7494
7526
  | Pending-crawl (\`uncrawled\`) rows present | "Re-run the import for those domains later, once Leadbay has crawled them" | leadbay_import_leads (re-run with just the uncrawled domains, later \u2014 they re-reconcile once crawled). NOTE: not a live-fetch of the added leads; those populate in the user's Leadbay account as the crawl completes |
7495
7527
  | Ambiguous / unresolved rows present | "Resolve the ambiguous rows" | leadbay_resolve_import_rows(records, identity_mappings)|
@@ -7499,9 +7531,11 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
7499
7531
  `;
7500
7532
  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_by_id. 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.
7501
7533
 
7534
+ SLOW BACKEND \u21D2 \`{status:'running', timed_out:true, importIds}\`. The wizard is sometimes slow; when the poll budget runs out this tool returns that SUCCESS result, not an error. The import is still running server-side. **Do NOT call leadbay_import_leads again** \u2014 that re-uploads the file and leaves a duplicate CRM-imports row. Call \`leadbay_import_status({importIds})\` after ~30s \u2014 and pass \`dry_run:true\` too if the result carried it; on \`complete\` it returns \`result.leads\` with the leadIds, while \`phase:"committing"\` just means keep polling. Tell the user it's running and you'll check back \u2014 it is not a problem to report. Exception: \`rows_pending_upload\` rows never reached the backend and DO need a fresh call for that subset only. In records mode the result also carries \`row_ids\` \u2014 the synthetic id of each input row, in your \`records[]\` order \u2014 because \`leadbay_import_status\` reports recovered leads by that id; keep it to map them back to your source rows.
7535
+
7502
7536
  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.
7503
7537
 
7504
- \`not_imported\` rows with \`reason:"uncrawled"\` are **pending a background crawl**, NOT failures: Leadbay just hasn't matched/crawled that domain yet and will add the lead asynchronously (the label doesn't verify the URL resolves \u2014 don't call the site bad, but don't certify it valid either). Surface them as pending; the leads populate in the user's Leadbay account as the crawl completes (no tool here fetches them on demand \u2014 \`leadbay_import_status\` returns status/progress only, and \`leadbay_pull_leads\` reads the active lens's wishlist so an imported lead outside that lens may not appear). To pull those specific companies back through the MCP, re-run the import later. A large \`uncrawled\` share on a fresh list is normal.
7538
+ \`not_imported\` rows with \`reason:"uncrawled"\` are **pending a background crawl**, NOT failures: Leadbay just hasn't matched/crawled that domain yet and will add the lead asynchronously (the label doesn't verify the URL resolves \u2014 don't call the site bad, but don't certify it valid either). Surface them as pending; the leads populate in the user's Leadbay account as the crawl completes (no tool here fetches them on demand \u2014 \`leadbay_import_status\` reports the rows the wizard has already placed, not leads a later crawl adds, and \`leadbay_pull_leads\` reads the active lens's wishlist so an imported lead outside that lens may not appear). To pull those specific companies back through the MCP, re-run the import later. A large \`uncrawled\` share on a fresh list is normal.
7505
7539
 
7506
7540
  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.
7507
7541
 
@@ -7532,7 +7566,8 @@ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before yo
7532
7566
  **Header \u2014 single line, choose by status:**
7533
7567
 
7534
7568
  - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
7535
- - Running: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
7569
+ - Running, \`handle_id\` present: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
7570
+ - Running with \`timed_out:true\` (blocking call ran out of poll budget): the import is FINE and still running server-side \u2014 never render this as an error or a failure. \`"\u23F3 Import still running (the backend is slow today) \u2014 I'll check back."\` Then call \`leadbay_import_status({importIds})\`, do NOT re-run leadbay_import_leads. If \`rows_pending_upload\` is present, add \`"\u26A0 K rows weren't submitted \u2014 re-import just those."\`
7536
7571
  - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
7537
7572
 
7538
7573
  Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
@@ -7581,7 +7616,9 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
7581
7616
 
7582
7617
  | Observation | Suggest | Calls |
7583
7618
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
7584
- | Status: running | "Check progress" | leadbay_import_status(handle_id) |
7619
+ | Status: running, \`handle_id\` present | "Check progress" | leadbay_import_status(handle_id) |
7620
+ | Status: running with \`timed_out:true\` | "Check progress" \u2014 NOT "retry the import" | leadbay_import_status(importIds, dry_run if the result carried it) after ~30s; \`result.leads\` carries the leadIds once complete |
7621
+ | \`rows_pending_upload\` present | "Import the rows that never got submitted" | leadbay_import_leads (that subset only) |
7585
7622
  | Status: complete, imports succeeded | "Run AI qualification on the imported leads" | leadbay_bulk_qualify_leads([leadIds]) \u2014 or use leadbay_import_and_qualify next time |
7586
7623
  | Pending-crawl (\`uncrawled\`) rows present | "Re-run the import for those domains later, once Leadbay has crawled them" | leadbay_import_leads (re-run with just the uncrawled domains, later \u2014 they re-reconcile once crawled). NOTE: not a live-fetch of the added leads; those populate in the user's Leadbay account as the crawl completes |
7587
7624
  | Ambiguous / unresolved rows present | "Resolve the ambiguous rows" | leadbay_resolve_import_rows(records, identity_mappings)|
@@ -7589,7 +7626,7 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
7589
7626
  | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
7590
7627
  | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
7591
7628
  `;
7592
- leadbay_import_status = `Retrieve the current **status/progress** of a lead import. Pass \`handle_id\` \u2014 returned by either \`leadbay_import_leads\` OR \`leadbay_import_and_qualify\` when called with \`wait_for_completion:false\` \u2014 to resolve the stored result (leads + not_imported) once that async run has completed in this MCP instance. **If you were given a \`handle_id\`, poll with it, not with \`importIds[]\`** \u2014 only the \`handle_id\` path returns the stored result/not_imported breakdown. Pass \`importIds[]\` (a completed import returns \`importIds\`; \`leadbay_import_and_qualify\` returns \`import_ids\`) only when you don't have a handle, to refresh the backend wizard rows' phase + record counts. Note: the \`importIds[]\` path returns status/progress only \u2014 it does NOT re-reconcile records or return refreshed leads/not_imported. This status call performs a single refresh pass and never polls in a loop.
7629
+ leadbay_import_status = `Retrieve the current **status/progress** of a lead import, and its leadIds once it finishes. Pass \`handle_id\` \u2014 returned by either \`leadbay_import_leads\` OR \`leadbay_import_and_qualify\` when called with \`wait_for_completion:false\` \u2014 to resolve the stored result (leads + not_imported) for that async run. Pass \`importIds[]\` when you don't have a handle: after a blocking \`leadbay_import_leads\` returned \`{status:'running', timed_out:true, importIds}\`, or from any completed import's \`importIds\` (\`leadbay_import_and_qualify\` calls the same field \`import_ids\`). **Both paths return leads.** The \`importIds[]\` path reads the wizard's records directly, so once every named import is \`complete\` (and it wasn't a dry run) the response carries \`result.leads\` + \`result.not_imported\` \u2014 that is how you recover the leadIds from an import that timed out mid-poll, without re-importing. \`result.still_settling\` counts rows the wizard hasn't finished placing; they are neither imported nor failed, so poll again rather than reporting them. If \`result\` is absent on a \`complete\` import the records weren't readable \u2014 report completion without inventing counts. \`phase:"committing"\` means the import is still being committed, NOT that it finished with nothing: keep polling. Pass \`dry_run:true\` when the importIds came from a dry run (a \`{timed_out:true}\` result carries \`dry_run\` \u2014 hand it straight back); a finished dry run and an import mid-commit are identical on the wire, so without that flag this tool reports \`committing\` rather than risk rendering a validation pass as a real import. This status call performs a single refresh pass and never polls in a loop.
7593
7630
 
7594
7631
  WHEN TO USE: after an async import (\`leadbay_import_leads\` OR \`leadbay_import_and_qualify\` with \`wait_for_completion:false\`) returns \`{status:'running', handle_id}\`, poll with that \`handle_id\`; OR to check whether a finished import is still processing. This tool does NOT surface the leads Leadbay adds later for pending-crawl (\`uncrawled\`) rows \u2014 those populate in the user's Leadbay account as the crawl completes; no tool here fetches them on demand (re-run the import to pull them back through the MCP).
7595
7632
 
@@ -7613,12 +7650,14 @@ After the status line, propose the obvious refresh / progress-check / recovery a
7613
7650
 
7614
7651
  Specifically for import status:
7615
7652
 
7616
- This tool returns \`status\`, \`importIds\`, and \`progress\` ({phase, records_processed, records_total}). It carries a \`result\` object (with \`leads\` + \`not_imported\`) ONLY when resolving an async \`handle_id\` whose run completed in this MCP instance \u2014 the \`importIds[]\` status-check path does NOT return \`result\`. **Render only from the fields actually present; never invent counts.**
7653
+ This tool returns \`status\`, \`importIds\`, and \`progress\` ({phase, records_processed, records_total}). It carries a \`result\` object (with \`leads\` + \`not_imported\`) when resolving an async \`handle_id\` whose run completed in this MCP instance, AND on the \`importIds[]\` path once every named import is \`complete\` and it wasn't a dry run. A \`complete\` import with no \`result\` means the records weren't readable \u2014 render completion only. **Render only from the fields actually present; never invent counts.**
7617
7654
 
7618
7655
  Caveat on \`progress\`: \`records_processed\` counts only the rows that MATCHED an existing lead (backend \`imported_records\`), not every row that finished processing \u2014 so for a complete import whose rows are mostly/all \`uncrawled\` (pending crawl), \`records_processed\` is legitimately low or 0. Never read a low \`records_processed\` on a \`complete\` import as "stuck" or "failed": once \`status:"complete"\`, processing is done; the pending-crawl rows just matched no existing lead yet.
7619
7656
 
7620
- - Running \u2192 \`"\u23F3 Import still running \u2014 phase <phase>; check back in ~M minutes."\` (use the phase; don't turn the matched-count into an "X/Y processed" progress bar).
7621
- - Complete, **no \`result\`** (the usual \`importIds\` status check) \u2192 \`"\u2713 Import complete."\` Do NOT append a \`records_processed/records_total\` fraction (it undercounts pending-crawl rows and looks stuck) and do NOT report pending-crawl / need-attention bucket counts \u2014 the row-level \`not_imported\` breakdown isn't in this response.
7657
+ - Running \u2192 \`"\u23F3 Import still running \u2014 phase <phase>; check back in ~M minutes."\` (use the phase; don't turn the matched-count into an "X/Y processed" progress bar). \`phase:"committing"\` is normal for an import that timed out \u2014 say it's still being committed, never that it failed or finished empty.
7658
+ - Complete with **\`dry_run:true\`** on the response \u2192 a VALIDATION pass; nothing was committed. \`"\u{1F50E} Dry run complete \u2014 input validated, nothing imported. Re-run without dry_run to commit."\` Never render this as a completed import, and never quote a lead count.
7659
+ - Complete, **no \`result\`** \u2192 \`"\u2713 Import complete."\` Do NOT append a \`records_processed/records_total\` fraction (it undercounts pending-crawl rows and looks stuck) and do NOT report pending-crawl / need-attention bucket counts \u2014 the row-level \`not_imported\` breakdown isn't in this response.
7660
+ - Complete with \`result.still_settling > 0\` \u2192 say \`"\u2713 Import complete \u2014 N imported, S rows still being placed."\` Never count \`still_settling\` rows as failures.
7622
7661
  - Complete, **\`result\` present AND it was a dry run** (\`result.dry_run:true\`, or every \`result.not_imported\` row has \`reason:"dry_run"\`) \u2192 this resolved handle was a VALIDATION pass, nothing committed. Render \`"\u{1F50E} Dry run complete \u2014 V rows validated, nothing imported. Re-run without dry_run to commit."\` \u2014 do NOT render it as a real import completion or use the pending/attention buckets.
7623
7662
  - Complete, **\`result\` present** (async handle resolved, real import) \u2192 then, and only then, partition \`result.not_imported\` as in the shared import-result render block below \u2014 \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` where **pending crawl** is \`uncrawled\` rows that HAVE a \`domain\` (not failures) and no-\`domain\` \`uncrawled\` rows fall under need-attention. Drop any zero segment.
7624
7663
  - Error / failed \u2192 \`"\u26A0 Import failed: <error>. See leadbay_resolve_import_rows for diagnosis."\` \u2014 reserve this ONLY for a true transport/backend error on the import itself, never for \`uncrawled\` rows.
@@ -7642,9 +7681,10 @@ How the OTHER reasons map to the "Need attention" bucket (see the render block a
7642
7681
 
7643
7682
  | Observation | Suggest | Calls |
7644
7683
  |--------------------------------------|------------------------------------------------------|--------------------------------|
7684
+ | Status: complete, \`result.leads\` present | "Qualify the imported leads" | leadbay_bulk_qualify_leads(result.leads[].leadId) |
7645
7685
  | Status: complete | "See the imported (matched) leads" | leadbay_pull_leads |
7646
7686
  | Pending-crawl (\`uncrawled\`) rows | "Re-run the import for those domains later, once Leadbay has crawled them" | leadbay_import_leads (re-run with just the uncrawled domains, later \u2014 they re-reconcile once crawled). The added leads otherwise populate in the user's Leadbay account as the crawl completes; no live-fetch here |
7647
- | Status: running | "Check again in N minutes" | leadbay_import_status \u2014 re-call|
7687
+ | Status: running (incl. \`committing\`) | "Check again in N minutes" | leadbay_import_status \u2014 re-call (pass \`dry_run:true\` if the ids came from a dry run) |
7648
7688
  | Status: error / failed (true error) | "Diagnose the failure" | leadbay_resolve_import_rows |
7649
7689
  `;
7650
7690
  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; a contact is done for this run only when the REQUESTED channel landed (requested \`email\` and/or \`phone_number\` present), not \`contact.enrichment.done\` alone (that flag is already true for a contact enriched on the other channel earlier). \`dry_run:true\` returns the call shape without contacting the backend.
@@ -9176,35 +9216,38 @@ Trigger phrases: "look up <Company>", "research <Company>", "what do we know abo
9176
9216
 
9177
9217
  Do NOT use for: "picked row with leadId" \u2192 \`leadbay_research_lead_by_id\`; "draft outreach for <Contact>" \u2192 \`leadbay_prepare_outreach\`.
9178
9218
 
9179
- Prefer when: company name in prose and no Leadbay id yet
9219
+ Prefer when: a company name or domain in prose, no Leadbay id yet \u2014 always pass \`website\` if a domain was mentioned
9180
9220
 
9181
9221
  Examples that SHOULD invoke this tool:
9182
9222
  - "Look up Acme Corp for me."
9183
9223
  - "Find Initech in my pipeline."
9224
+ - "Who is Wink Lab? Their email is at @wink-lab.com."
9184
9225
 
9185
9226
  Examples that should NOT invoke this tool (sound similar, route elsewhere):
9186
9227
  - "Tell me about that lead I just picked."
9187
9228
  - "Draft outreach to Acme's CTO."
9229
+ - "Show me today's leads."
9188
9230
 
9189
9231
  ---
9190
9232
 
9191
- Resolves \`companyName\` across visible Discover, Monitor, and Activate leads,
9192
- then delegates to **leadbay_research_lead_by_id**. Supplying \`lensId\`
9193
- deliberately restricts the backend search to that lens. The result matches
9194
- \`_by_id\`, plus:
9233
+ Resolves across the user's visible Discover/Monitor/Activate leads AND the
9234
+ **Leadbay company registry** \u2014 so a company they do not own yet is still
9235
+ findable \u2014 then delegates to **leadbay_research_lead_by_id**.
9195
9236
 
9196
- - \`_meta.resolved_from\`: \`"companyName"\`
9197
- - \`_meta.resolved_query\`: the original query
9198
- - \`_meta.match_candidates[]\`: up to 4 \`{leadId, name, score}\` alternatives
9237
+ **Pass \`website\` whenever the user mentioned a domain** \u2014 the strongest match
9238
+ key. It survives a misspelled company name and is what turns "not in your
9239
+ list" into an answer. With only a contact email, pass \`email\`: the company
9240
+ domain is derived from it, consumer mailboxes ignored.
9199
9241
 
9200
- \`LEAD_NOT_FOUND\` identifies whether the complete visible corpus, an explicit
9201
- lens, or only a degraded active-lens fallback was searched.
9242
+ When the registry cannot pick one company it returns \`{resolution:
9243
+ "ambiguous", query, candidates:[{leadId, name, website, location, \u2026}]}\`
9244
+ instead of a card. Ask which one; never guess from \`score\`.
9202
9245
 
9203
- WHEN TO USE: for a company/domain/contact reference
9204
- without a \`lead_id\`. Offer \`_meta.match_candidates\` when present.
9246
+ \`LEAD_NOT_FOUND\` is not a dead end: its hint names the field that would have
9247
+ found it \u2014 \`website\` or \`registry_number\`, both params. Ask for it and call
9248
+ again. Do not offer an import before asking.
9205
9249
 
9206
- WHEN NOT TO USE: with a UUID; call
9207
- leadbay_research_lead_by_id directly.
9250
+ Offer \`_meta.match_candidates\` when present.
9208
9251
 
9209
9252
  ---
9210
9253
 
@@ -9343,6 +9386,10 @@ out?"\`
9343
9386
  | User is done with this lead | "Back to the inbox" | leadbay_pull_leads |
9344
9387
 
9345
9388
 
9389
+ When \`resolution\` is \`"ambiguous"\`, render no card: use \`ask_user_input_v0\`,
9390
+ ONE \`single_select\` question ("Which one?"), one short label per candidate
9391
+ combining \`name\` and \`location\`.
9392
+
9346
9393
  When \`_meta.match_candidates\` is non-empty, prepend one extra NEXT STEPS row:
9347
9394
 
9348
9395
  | Observation | Suggest | Calls |
@@ -9737,6 +9784,84 @@ WHEN TO USE: low-level.
9737
9784
 
9738
9785
  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.
9739
9786
 
9787
+ 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\`.
9788
+ `;
9789
+ leadbay_set_lead_status = `## WHEN TO USE
9790
+
9791
+ Trigger phrases: "we won this deal", "mark this lead as won", "we lost them", "mark as lost", "this one is a target", "add them to my wanted list", "set the status on these leads", "closed the deal with", "they signed", "not a target anymore".
9792
+
9793
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
9794
+
9795
+ Do NOT use for: "I sent the email / left a voicemail \u2014 log the outcome" \u2192 \`leadbay_report_outreach\`; "thumbs up, I like this lead" \u2192 \`leadbay_like_lead\`; "remind me about this lead next week / snooze it" \u2192 \`leadbay_set_pushback\`.
9796
+
9797
+ Prefer when: user states a COMMERCIAL outcome or pipeline stage, not an outreach event; pass \`lead_ids\` + the uppercase \`status\`, and \`status_date\` when they name a close date
9798
+
9799
+ Examples that SHOULD invoke this tool:
9800
+ - "We just signed Acme Corp \u2014 mark them as won."
9801
+ - "Mark these three as lost, they went with a competitor."
9802
+ - "Add Northwind to my wanted list, they're a priority target."
9803
+
9804
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
9805
+ - "I emailed the CTO this morning, log it."
9806
+ - "Thumbs up on this one, show me more like it."
9807
+ - "Snooze this lead until next quarter."
9808
+
9809
+ ## RENDER (quick)
9810
+
9811
+ One short confirmation line per status applied ("\u2705 **Acme Corp** \u2192 WON
9812
+ (closed 2026-03-14)"). If \`failed\` is non-empty, list those leads with
9813
+ their error underneath. Don't re-render the full lead card.
9814
+
9815
+ ---
9816
+
9817
+ Set the **org-wide CRM lead status** \u2014 the same field the Leadbay website's status
9818
+ selector writes, and the one a CSV import maps via \`mappings.statuses\`. It is shared
9819
+ across the whole organization: every rep sees the value this call sets.
9820
+
9821
+ Two distinct status systems exist in Leadbay. Do not confuse them:
9822
+
9823
+ | System | Values | Written by | Means |
9824
+ |---|---|---|---|
9825
+ | **Lead status** (this tool) | \`WANTED\` \`WON\` \`LOST\` \`UNWANTED\` (plus system-set \`DEFAULT\`, \`INBOUND\`) | this tool, CSV import | Commercial/pipeline outcome, org-wide |
9826
+ | **Epilogue status** | \`STILL_CHASING\` \`COULD_NOT_REACH_STILL_TRYING\` \`INTEREST_VALIDATED_OR_MEETING_PLANED\` \`NOT_INTERESTED_LOST\` | \`leadbay_report_outreach\`, \`leadbay_set_epilogue_status\` | Disposition of a specific outreach attempt; drives \`leadbay_pull_followups\` ranking |
9827
+
9828
+ A deal outcome is a **lead status**. "She didn't pick up" is an **epilogue status**.
9829
+ Setting one never sets the other \u2014 when the user reports both in one breath ("called
9830
+ them, they signed"), make both calls.
9831
+
9832
+ ## Parameters
9833
+
9834
+ - \`lead_ids\` (required) \u2014 1\u2013200 lead UUIDs. Every lead gets the same status.
9835
+ - \`status\` (required) \u2014 one of \`WANTED\`, \`WON\`, \`LOST\`, \`UNWANTED\`. Accepted
9836
+ case-insensitively (\`won\` \u2192 \`WON\`); no synonyms are guessed, so "closed-won"
9837
+ or "dead" are rejected rather than silently mapped. \`DEFAULT\` and \`INBOUND\`
9838
+ are accepted but are normally set by Leadbay itself \u2014 don't offer them as
9839
+ user choices.
9840
+ - \`status_date\` (optional) \u2014 \`YYYY-MM-DD\`, the date the status was actually
9841
+ reached (a close date, the day the deal was lost). Omit it and the backend
9842
+ stamps now. Pass it whenever the user names a date; a deal closed last month
9843
+ stamped as today distorts every pipeline report.
9844
+
9845
+ ## Behaviour
9846
+
9847
+ Each lead is written individually (\`POST /leads/{leadId}/set_status\`, then
9848
+ \`POST /leads/{leadId}/set_status_date\` when \`status_date\` is given), so a partial
9849
+ failure is possible. The return is
9850
+ \`{ applied, count, status, status_date?, failed: [{lead_id, message}] }\` \u2014
9851
+ **always check \`failed\`** and report those leads to the user rather than claiming
9852
+ a clean sweep. \`applied\` is \`false\` when every lead failed.
9853
+
9854
+ Re-sending the same status is idempotent \u2014 no error, no duplicate entry.
9855
+
9856
+ WHEN TO USE: the user states a commercial outcome or pipeline
9857
+ position for specific leads: "we won them", "that one's dead", "these are my targets
9858
+ this quarter". Also use it from an artifact's status dropdown.
9859
+
9860
+ WHEN NOT TO USE: the user is reporting that an outreach
9861
+ *happened* (use \`leadbay_report_outreach\` \u2014 its \`epilogue_status\` covers the
9862
+ follow-up disposition), expressing taste rather than an outcome (\`leadbay_like_lead\`
9863
+ / \`leadbay_dislike_lead\`), or temporarily deferring a lead (\`leadbay_set_pushback\`).
9864
+
9740
9865
  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\`.
9741
9866
  `;
9742
9867
  leadbay_set_pushback = `Snooze (pushback) one or more leads for 3, 6, or 12 months. The leads remain in the user's pipeline but are excluded from \`leadbay_pull_followups\` until the pushback window expires. Use this when the user says "not now", "next quarter", "follow up in 3 months", "6 months out", "next year", or any equivalent deferral.
@@ -10528,6 +10653,52 @@ var init_get_contacts = __esm({
10528
10653
  required: ["leadId"],
10529
10654
  additionalProperties: false
10530
10655
  },
10656
+ outputSchema: {
10657
+ type: "object",
10658
+ properties: {
10659
+ contacts: {
10660
+ type: "array",
10661
+ description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
10662
+ items: {
10663
+ type: "object",
10664
+ properties: {
10665
+ id: { type: "string" },
10666
+ first_name: { type: ["string", "null"] },
10667
+ last_name: { type: ["string", "null"] },
10668
+ email: { type: ["string", "null"] },
10669
+ phone_number: { type: ["string", "null"] },
10670
+ linkedin_page: { type: ["string", "null"] },
10671
+ job_title: { type: ["string", "null"] },
10672
+ recommended: { type: "boolean" },
10673
+ source: { type: "string", enum: ["org", "paid"] },
10674
+ enrichment: {
10675
+ type: ["object", "null"],
10676
+ description: "Per-contact reveal record. Missing or null = the contact was NEVER requested (enrichable \u2014 not the same as done:false). Read `done` and `credits_used` TOGETHER: done:false = reservation in flight, poll and do not re-launch; done:true with credits_used:0 = the reveal SETTLED and found nothing, which is TERMINAL \u2014 do not re-attempt it on a later run; done:true with credits_used>0 = resolved, but the revealed channel lands on the source:'org' twin of this person, not on the source:'paid' record itself, so a null email here is not a failure. credits_used:0 on its own is NOT a verdict (an in-flight reservation reports 0 too), and an ABSENT credits_used means the cost is unknown, not zero.",
10677
+ properties: {
10678
+ done: {
10679
+ type: "boolean",
10680
+ description: "False = reservation in flight. True = settled, either with a result (credits_used>0) or empty (credits_used:0). Per-contact, not per-channel."
10681
+ },
10682
+ credits_used: {
10683
+ type: "number",
10684
+ description: "Credits charged for this reveal. Only meaningful when done:true. An explicit 0 alongside done:true means the provider returned nothing. Optional \u2014 when absent the cost is unknown and terminal-empty must NOT be inferred."
10685
+ },
10686
+ email_requested: { type: "boolean" },
10687
+ phone_requested: { type: "boolean" }
10688
+ }
10689
+ }
10690
+ },
10691
+ required: ["id", "source"]
10692
+ }
10693
+ },
10694
+ _fetch_errors: {
10695
+ type: "array",
10696
+ description: "Present only when one of the two contact endpoints failed. Each: {endpoint:'org'|'paid', code?, retry_after?}. A rejected endpoint contributes no contacts, so an empty `contacts` alongside this field is a fetch failure, NOT 'no contacts'.",
10697
+ items: { type: "object" }
10698
+ }
10699
+ },
10700
+ required: ["contacts"]
10701
+ },
10531
10702
  execute: async (client, params) => {
10532
10703
  const [orgResult, paidResult] = await Promise.allSettled([
10533
10704
  client.request("GET", `/leads/${params.leadId}/contacts?IncludeEnriched=true`),
@@ -12895,18 +13066,7 @@ var init_qualify_helpers = __esm({
12895
13066
  }
12896
13067
  });
12897
13068
 
12898
- // ../core/dist/composite/import-leads.js
12899
- import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
12900
- function isImportLeadsRunningResult(result) {
12901
- return "status" in result && result.status === "running";
12902
- }
12903
- function isCustomFieldMappingValue(v) {
12904
- return CUSTOM_FIELD_RE.test(v);
12905
- }
12906
- function customFieldIdOf(v) {
12907
- const m = CUSTOM_FIELD_RE.exec(v);
12908
- return m ? m[1] : null;
12909
- }
13069
+ // ../core/dist/composite/_import-records.js
12910
13070
  function normalizeDomain(input) {
12911
13071
  if (!input || typeof input !== "string")
12912
13072
  return null;
@@ -12937,6 +13097,214 @@ function normalizeDomain(input) {
12937
13097
  return null;
12938
13098
  return v;
12939
13099
  }
13100
+ function ourRowId(raw) {
13101
+ if (raw == null)
13102
+ return void 0;
13103
+ const v = raw.trim();
13104
+ return MCP_ROW_ID_RE.test(v) ? v : void 0;
13105
+ }
13106
+ function cellNames(c) {
13107
+ return [c?.column_name, c?.key, c?.field].filter((n) => n != null && n !== "").map((n) => String(n).toLowerCase());
13108
+ }
13109
+ function readCell(record, key) {
13110
+ const want = key.toLowerCase();
13111
+ const arr = record.records;
13112
+ if (Array.isArray(arr)) {
13113
+ for (const c of arr) {
13114
+ if (cellNames(c).includes(want)) {
13115
+ const v = c?.value ?? null;
13116
+ return v != null ? String(v) : null;
13117
+ }
13118
+ }
13119
+ }
13120
+ const cells = record.cells;
13121
+ if (cells && typeof cells === "object" && !Array.isArray(cells)) {
13122
+ for (const [k, v] of Object.entries(cells)) {
13123
+ if (k.toLowerCase() === want) {
13124
+ return v != null ? String(v) : null;
13125
+ }
13126
+ }
13127
+ }
13128
+ if (Array.isArray(cells)) {
13129
+ for (const c of cells) {
13130
+ if (cellNames(c).includes(want)) {
13131
+ const v = c?.value ?? null;
13132
+ return v != null ? String(v) : null;
13133
+ }
13134
+ }
13135
+ }
13136
+ return null;
13137
+ }
13138
+ function recordMatchType(record) {
13139
+ return (record.match_type ?? record.matchType ?? "").toString().toUpperCase();
13140
+ }
13141
+ function isRecordTerminal(record) {
13142
+ const status = (record.status ?? "").toString().toUpperCase();
13143
+ return recordMatchType(record) === "NO_MATCH" || status === "IMPORTED";
13144
+ }
13145
+ function settlingDeficit(declaredTotal, fetched) {
13146
+ return Math.max(0, declaredTotal - fetched);
13147
+ }
13148
+ function reconcileRecords(records) {
13149
+ const leads = [];
13150
+ const not_imported = [];
13151
+ const pendingLeadIds = /* @__PURE__ */ new Set();
13152
+ let pending = 0;
13153
+ let distinct = 0;
13154
+ const seenRowIds = /* @__PURE__ */ new Set();
13155
+ for (const rec of records) {
13156
+ const rowId = ourRowId(readCell(rec, MCP_ROW_ID_COLUMN));
13157
+ const dedupeKey = rowId !== void 0 ? `row:${rowId}` : rec.id != null ? `rec:${String(rec.id)}` : null;
13158
+ if (dedupeKey !== null) {
13159
+ if (seenRowIds.has(dedupeKey))
13160
+ continue;
13161
+ seenRowIds.add(dedupeKey);
13162
+ }
13163
+ distinct++;
13164
+ const websiteCell = readCell(rec, "LEAD_WEBSITE");
13165
+ const domain = normalizeDomain(websiteCell ?? "") ?? normalizeDomain(rec.lead?.website ?? "") ?? void 0;
13166
+ if (!isRecordTerminal(rec)) {
13167
+ pending++;
13168
+ if (rec.lead?.id)
13169
+ pendingLeadIds.add(rec.lead.id);
13170
+ continue;
13171
+ }
13172
+ if (rec.lead?.id) {
13173
+ leads.push({
13174
+ ...rowId ? { rowId } : {},
13175
+ ...domain ? { domain } : {},
13176
+ leadId: rec.lead.id,
13177
+ name: rec.lead.name ?? null
13178
+ });
13179
+ continue;
13180
+ }
13181
+ if (recordMatchType(rec) === "NO_MATCH") {
13182
+ not_imported.push({
13183
+ ...rowId ? { rowId } : {},
13184
+ ...domain ? { domain } : {},
13185
+ reason: domain && PUBLIC_MAILBOX_DOMAINS.has(domain) ? "no_match" : "uncrawled"
13186
+ });
13187
+ continue;
13188
+ }
13189
+ pending++;
13190
+ }
13191
+ return { leads, not_imported, pending, distinct, pendingLeadIds };
13192
+ }
13193
+ var PUBLIC_MAILBOX_DOMAINS, MCP_ROW_ID_COLUMN, MCP_ROW_ID_RE;
13194
+ var init_import_records = __esm({
13195
+ "../core/dist/composite/_import-records.js"() {
13196
+ "use strict";
13197
+ PUBLIC_MAILBOX_DOMAINS = /* @__PURE__ */ new Set([
13198
+ "gmail.com",
13199
+ "googlemail.com",
13200
+ "yahoo.com",
13201
+ "ymail.com",
13202
+ "outlook.com",
13203
+ "hotmail.com",
13204
+ "live.com",
13205
+ "icloud.com",
13206
+ "me.com",
13207
+ "mac.com",
13208
+ "aol.com",
13209
+ "proton.me",
13210
+ "protonmail.com",
13211
+ "tutanota.com",
13212
+ "gmx.com",
13213
+ "gmx.net",
13214
+ "gmx.de",
13215
+ "mail.com",
13216
+ "yandex.com",
13217
+ "yandex.ru",
13218
+ "qq.com",
13219
+ "163.com",
13220
+ "126.com",
13221
+ // Regional aliases of the same providers, plus the consumer ISP mailboxes
13222
+ // that dominate a French user base. Without these, `orange.fr` or
13223
+ // `yahoo.fr` reads as a company domain (codex review, mcp#188).
13224
+ "yahoo.fr",
13225
+ "yahoo.co.uk",
13226
+ "yahoo.es",
13227
+ "yahoo.it",
13228
+ "yahoo.de",
13229
+ "yahoo.ca",
13230
+ "yahoo.com.br",
13231
+ "yahoo.co.jp",
13232
+ "hotmail.fr",
13233
+ "hotmail.co.uk",
13234
+ "hotmail.es",
13235
+ "hotmail.it",
13236
+ "hotmail.de",
13237
+ "hotmail.be",
13238
+ "outlook.fr",
13239
+ "outlook.es",
13240
+ "outlook.de",
13241
+ "outlook.it",
13242
+ "live.fr",
13243
+ "live.be",
13244
+ "live.co.uk",
13245
+ "msn.com",
13246
+ "orange.fr",
13247
+ "wanadoo.fr",
13248
+ "free.fr",
13249
+ "sfr.fr",
13250
+ "laposte.net",
13251
+ "bbox.fr",
13252
+ "neuf.fr",
13253
+ "aliceadsl.fr",
13254
+ "numericable.fr",
13255
+ "club-internet.fr",
13256
+ "gmx.fr",
13257
+ "gmx.at",
13258
+ "gmx.ch",
13259
+ "web.de",
13260
+ "t-online.de",
13261
+ "libero.it",
13262
+ "wp.pl",
13263
+ "seznam.cz"
13264
+ ]);
13265
+ MCP_ROW_ID_COLUMN = "MCP_ROW_ID";
13266
+ MCP_ROW_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13267
+ }
13268
+ });
13269
+
13270
+ // ../core/dist/composite/_import-commit-log.js
13271
+ function recordCommitFailure(importId, reason) {
13272
+ if (failures.size >= MAX_ENTRIES) {
13273
+ const oldest = failures.keys().next().value;
13274
+ if (oldest !== void 0)
13275
+ failures.delete(oldest);
13276
+ }
13277
+ failures.set(importId, reason);
13278
+ }
13279
+ function commitFailureFor(importIds) {
13280
+ for (const id of importIds) {
13281
+ const reason = failures.get(id);
13282
+ if (reason !== void 0)
13283
+ return reason;
13284
+ }
13285
+ return void 0;
13286
+ }
13287
+ var MAX_ENTRIES, failures;
13288
+ var init_import_commit_log = __esm({
13289
+ "../core/dist/composite/_import-commit-log.js"() {
13290
+ "use strict";
13291
+ MAX_ENTRIES = 500;
13292
+ failures = /* @__PURE__ */ new Map();
13293
+ }
13294
+ });
13295
+
13296
+ // ../core/dist/composite/import-leads.js
13297
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
13298
+ function isImportLeadsRunningResult(result) {
13299
+ return "status" in result && result.status === "running";
13300
+ }
13301
+ function isCustomFieldMappingValue(v) {
13302
+ return CUSTOM_FIELD_RE.test(v);
13303
+ }
13304
+ function customFieldIdOf(v) {
13305
+ const m = CUSTOM_FIELD_RE.exec(v);
13306
+ return m ? m[1] : null;
13307
+ }
12940
13308
  function escapeCsvCell(raw) {
12941
13309
  if (raw == null)
12942
13310
  return "";
@@ -13016,37 +13384,6 @@ async function sleepWithAbort2(ms, signal) {
13016
13384
  signal.addEventListener("abort", onAbort, { once: true });
13017
13385
  });
13018
13386
  }
13019
- function readCell(record, key) {
13020
- const want = key.toLowerCase();
13021
- const arr = record.records;
13022
- if (Array.isArray(arr)) {
13023
- for (const c of arr) {
13024
- const k = (c?.column_name ?? c?.key ?? c?.field ?? "").toString().toLowerCase();
13025
- if (k === want) {
13026
- const v = c?.value ?? null;
13027
- return v != null ? String(v) : null;
13028
- }
13029
- }
13030
- }
13031
- const cells = record.cells;
13032
- if (cells && typeof cells === "object" && !Array.isArray(cells)) {
13033
- for (const [k, v] of Object.entries(cells)) {
13034
- if (k.toLowerCase() === want) {
13035
- return v != null ? String(v) : null;
13036
- }
13037
- }
13038
- }
13039
- if (Array.isArray(cells)) {
13040
- for (const c of cells) {
13041
- const k = (c?.key ?? c?.field ?? c?.column_name ?? "").toString().toLowerCase();
13042
- if (k === want) {
13043
- const v = c?.value ?? null;
13044
- return v != null ? String(v) : null;
13045
- }
13046
- }
13047
- }
13048
- return null;
13049
- }
13050
13387
  function validateColumnName(client, name, path) {
13051
13388
  if (typeof name !== "string" || name.length === 0) {
13052
13389
  throw client.makeError("IMPORT_INVALID_COLUMN_NAME", `Column name at ${path} must be a non-empty string`, `Use a plain string column name (1-${MAX_COLUMN_NAME_LEN} chars).`, "POST /imports");
@@ -13296,7 +13633,7 @@ async function pollUntil(fn, done, budgetMs, signal, ctx, label) {
13296
13633
  async function pollPreprocess(client, importId, budgetMs, ctx, signal) {
13297
13634
  const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.pre_processing?.finished), budgetMs, signal, ctx, "preprocess");
13298
13635
  if (!result.pre_processing?.finished) {
13299
- throw client.makeError("IMPORT_BUDGET_EXHAUSTED", `Preprocess phase did not finish within ${budgetMs}ms`, `Increase per_phase_budget_ms (current: ${budgetMs}) or split the batch. importId=${importId}.`, `GET /imports/${importId}`);
13636
+ throw new ImportPhaseTimeout("preprocess", importId, budgetMs);
13300
13637
  }
13301
13638
  if (result.pre_processing.error) {
13302
13639
  throw client.makeError("IMPORT_PREPROCESS_FAILED", `Preprocess failed: ${result.pre_processing.error}`, `Check the input domains. importId=${importId} for backend debugging.`, `GET /imports/${importId}`);
@@ -13306,7 +13643,7 @@ async function pollPreprocess(client, importId, budgetMs, ctx, signal) {
13306
13643
  async function pollProcess(client, importId, budgetMs, ctx, signal) {
13307
13644
  const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.processing?.finished), budgetMs, signal, ctx, "process");
13308
13645
  if (!result.processing?.finished) {
13309
- throw client.makeError("IMPORT_BUDGET_EXHAUSTED", `Process phase did not finish within ${budgetMs}ms`, `Increase per_phase_budget_ms (current: ${budgetMs}) or split the batch. importId=${importId}.`, `GET /imports/${importId}`);
13646
+ throw new ImportPhaseTimeout("process", importId, budgetMs);
13310
13647
  }
13311
13648
  if (result.processing.error != null) {
13312
13649
  throw client.makeError("IMPORT_PROCESSING_FAILED", `Backend processing failed: ${result.processing.error}`, `importId=${importId}.`, `GET /imports/${importId}`);
@@ -13333,10 +13670,7 @@ async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCoun
13333
13670
  records.push(...res.items);
13334
13671
  total = res.pagination.total ?? records.length;
13335
13672
  for (const r of res.items) {
13336
- const status = (r.status ?? "").toString().toUpperCase();
13337
- const matchType = (r.match_type ?? r.matchType ?? "").toString().toUpperCase();
13338
- const isTerminal = matchType === "NO_MATCH" || status === "IMPORTED";
13339
- if (!isTerminal)
13673
+ if (!isRecordTerminal(r))
13340
13674
  transient++;
13341
13675
  }
13342
13676
  const totalPages = res.pagination.pages ?? 0;
@@ -13364,14 +13698,15 @@ async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCoun
13364
13698
  }
13365
13699
  if (Date.now() >= deadline) {
13366
13700
  ctx?.logger?.warn?.(`import-leads: records did not stabilize (transient=${transient}, total=${total}); returning best-effort`);
13367
- throw client.makeError("IMPORT_NOT_TERMINAL", `Backend hasn't fully settled records within ${budgetMs}ms`, `Retry leadbay_import_leads with the same input in 30s, or split the batch. importId=${importId}.`, `GET /imports/${importId}/records`);
13701
+ throw new ImportPhaseTimeout("reconcile", importId, budgetMs);
13368
13702
  }
13369
13703
  await sleepWithAbort2(POLL_INTERVAL_MS2, signal);
13370
13704
  }
13371
13705
  }
13372
- async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId) {
13706
+ async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId, onNotificationId, onUploaded) {
13373
13707
  const upload = await uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx, onImportId);
13374
- return completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal);
13708
+ onUploaded?.(upload);
13709
+ return completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onNotificationId);
13375
13710
  }
13376
13711
  async function uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx, onImportId) {
13377
13712
  const csv = synthesizeCsv(header, chunk.map((c) => c.row));
@@ -13383,27 +13718,30 @@ async function uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx,
13383
13718
  onImportId(importId);
13384
13719
  return { importId, chunk, chunkIdx, totalChunks };
13385
13720
  }
13386
- async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal) {
13387
- const { importId, chunk } = upload;
13388
- const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
13389
- await pollPreprocess(client, importId, phaseBudget, ctx, signal);
13390
- ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
13391
- if (dryRun) {
13392
- return { importId, records: [], notification_id: null };
13393
- }
13394
- let updateMappingsResp = null;
13721
+ async function commitMappings(client, importId, mappings, ctx) {
13395
13722
  try {
13396
- updateMappingsResp = await client.request("POST", `/imports/${importId}/update_mappings`, mappings);
13723
+ const resp = await client.request("POST", `/imports/${importId}/update_mappings`, mappings);
13724
+ return resp?.notification_id ?? null;
13397
13725
  } catch (err) {
13398
13726
  if (err?.code === "API_ERROR" || err?.code === "NOT_FOUND") {
13399
13727
  ctx?.logger?.warn?.(`import-leads: update_mappings raw error (${err?.code}); retrying void`);
13400
13728
  await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
13401
- } else {
13402
- throw err;
13729
+ return null;
13403
13730
  }
13731
+ throw err;
13732
+ }
13733
+ }
13734
+ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onNotificationId) {
13735
+ const { importId, chunk } = upload;
13736
+ const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
13737
+ await pollPreprocess(client, importId, phaseBudget, ctx, signal);
13738
+ ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
13739
+ if (dryRun) {
13740
+ return { importId, records: [], notification_id: null };
13404
13741
  }
13405
- const importNotificationId = updateMappingsResp?.notification_id ?? null;
13742
+ const importNotificationId = await commitMappings(client, importId, mappings, ctx);
13406
13743
  if (importNotificationId) {
13744
+ onNotificationId?.(importNotificationId);
13407
13745
  ctx?.logger?.info?.(`import-leads: notification_id=${importNotificationId} importId=${importId}`);
13408
13746
  }
13409
13747
  ctx?.logger?.info?.(`import-leads: mappings committed for importId=${importId}`);
@@ -13454,7 +13792,7 @@ function reconcileOneChunk(prep, chunk, matched, notImported) {
13454
13792
  }
13455
13793
  seenInputIndex.add(inputIdx);
13456
13794
  const inp = prep.validInputs[inputIdx];
13457
- const matchType = (rec.match_type ?? rec.matchType ?? "").toString();
13795
+ const matchType = recordMatchType(rec);
13458
13796
  if (rec.lead?.id) {
13459
13797
  matched.set(inputIdx, {
13460
13798
  domain: inp.outputDomain,
@@ -13546,6 +13884,19 @@ function buildImportLeadsResult(client, prep, importIds, matched, notImported, d
13546
13884
  }
13547
13885
  };
13548
13886
  }
13887
+ function resumeParkedUpload(client, upload, mappings, ctx) {
13888
+ const bgCtx = { logger: ctx?.logger };
13889
+ const { importId } = upload;
13890
+ setTimeout(() => {
13891
+ void (async () => {
13892
+ await pollPreprocess(client, importId, RESUME_COMMIT_BUDGET_MS, bgCtx, void 0);
13893
+ await commitMappings(client, importId, mappings, bgCtx);
13894
+ })().then(() => ctx?.logger?.info?.(`import-leads: parked upload ${importId} committed; backend is processing`), (err) => {
13895
+ recordCommitFailure(importId, err?.message ?? err?.code ?? "mapping commit failed");
13896
+ ctx?.logger?.warn?.(`import-leads: parked upload ${importId} could not be committed (${err?.code ?? err?.message ?? "unknown"})`);
13897
+ });
13898
+ }, 0);
13899
+ }
13549
13900
  async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, handleId) {
13550
13901
  const tracker = ctx.bulkTracker;
13551
13902
  if (!tracker)
@@ -13586,11 +13937,13 @@ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, ha
13586
13937
  })();
13587
13938
  }, 0);
13588
13939
  }
13589
- var CHUNK_SIZE, POLL_INTERVAL_MS2, DEFAULT_PER_PHASE_BUDGET_MS, DEFAULT_TOTAL_BUDGET_MS, STABILIZATION_POLLS, MAX_COLUMN_NAME_LEN, RESERVED_COLUMN_RE, CUSTOM_FIELD_RE, IMPORT_RESOLVER_FIELDS, PUBLIC_MAILBOX_DOMAINS, LEAD_STATUSES, LEAD_STATUS_SET, importLeads;
13940
+ var CHUNK_SIZE, POLL_INTERVAL_MS2, DEFAULT_PER_PHASE_BUDGET_MS, DEFAULT_TOTAL_BUDGET_MS, STABILIZATION_POLLS, MAX_COLUMN_NAME_LEN, RESERVED_COLUMN_RE, CUSTOM_FIELD_RE, IMPORT_RESOLVER_FIELDS, ImportPhaseTimeout, LEAD_STATUSES, LEAD_STATUS_SET, importLeads, RESUME_COMMIT_BUDGET_MS;
13590
13941
  var init_import_leads = __esm({
13591
13942
  "../core/dist/composite/import-leads.js"() {
13592
13943
  "use strict";
13593
13944
  init_tool_descriptions_generated();
13945
+ init_import_records();
13946
+ init_import_commit_log();
13594
13947
  CHUNK_SIZE = 100;
13595
13948
  POLL_INTERVAL_MS2 = 2e3;
13596
13949
  DEFAULT_PER_PHASE_BUDGET_MS = 6e4;
@@ -13606,31 +13959,19 @@ var init_import_leads = __esm({
13606
13959
  "LEAD_WEBSITE",
13607
13960
  "SIREN"
13608
13961
  ]);
13609
- PUBLIC_MAILBOX_DOMAINS = /* @__PURE__ */ new Set([
13610
- "gmail.com",
13611
- "googlemail.com",
13612
- "yahoo.com",
13613
- "ymail.com",
13614
- "outlook.com",
13615
- "hotmail.com",
13616
- "live.com",
13617
- "icloud.com",
13618
- "me.com",
13619
- "mac.com",
13620
- "aol.com",
13621
- "proton.me",
13622
- "protonmail.com",
13623
- "tutanota.com",
13624
- "gmx.com",
13625
- "gmx.net",
13626
- "gmx.de",
13627
- "mail.com",
13628
- "yandex.com",
13629
- "yandex.ru",
13630
- "qq.com",
13631
- "163.com",
13632
- "126.com"
13633
- ]);
13962
+ ImportPhaseTimeout = class extends Error {
13963
+ phase;
13964
+ importId;
13965
+ budgetMs;
13966
+ code = "IMPORT_TIMEOUT";
13967
+ constructor(phase, importId, budgetMs) {
13968
+ super(`Import ${phase} phase did not finish within ${budgetMs}ms; the wizard is still running server-side. Poll leadbay_import_status with importIds=["${importId}"].`);
13969
+ this.phase = phase;
13970
+ this.importId = importId;
13971
+ this.budgetMs = budgetMs;
13972
+ this.name = "ImportPhaseTimeout";
13973
+ }
13974
+ };
13634
13975
  LEAD_STATUSES = [
13635
13976
  "DEFAULT",
13636
13977
  "INBOUND",
@@ -13743,7 +14084,20 @@ var init_import_leads = __esm({
13743
14084
  },
13744
14085
  handle_id: {
13745
14086
  type: "string",
13746
- description: "Persisted UUID handle to pass to leadbay_import_status."
14087
+ description: "Persisted UUID handle to pass to leadbay_import_status. Only on the wait_for_completion=false path; absent when a blocking call timed out (use importIds then)."
14088
+ },
14089
+ timed_out: {
14090
+ type: "boolean",
14091
+ description: "True when a blocking call ran out of poll budget. The import is still running server-side \u2014 poll leadbay_import_status(importIds). Do NOT re-issue the import."
14092
+ },
14093
+ rows_pending_upload: {
14094
+ type: "number",
14095
+ description: "Rows from later chunks that were never uploaded before the budget ran out. These are NOT running anywhere; re-import just those rows."
14096
+ },
14097
+ row_ids: {
14098
+ type: "array",
14099
+ description: "Records mode only: the synthetic MCP_ROW_ID of each input row, in the order you passed `records[]`. leadbay_import_status reports recovered leads by that id \u2014 use this to map them back to your source rows.",
14100
+ items: { type: "string" }
13747
14101
  },
13748
14102
  progress: {
13749
14103
  type: "object",
@@ -13773,7 +14127,7 @@ var init_import_leads = __esm({
13773
14127
  required: ["importIds", "region", "_meta"],
13774
14128
  anyOf: [
13775
14129
  { required: ["leads", "not_imported", "importIds", "region", "_meta"] },
13776
- { required: ["status", "handle_id", "importIds", "progress", "region", "_meta"] }
14130
+ { required: ["status", "importIds", "progress", "region", "_meta"] }
13777
14131
  ]
13778
14132
  },
13779
14133
  execute: async (client, params, ctx) => {
@@ -13900,23 +14254,33 @@ var init_import_leads = __esm({
13900
14254
  const matched = /* @__PURE__ */ new Map();
13901
14255
  const notImported = /* @__PURE__ */ new Map();
13902
14256
  let cancelled = false;
14257
+ let timedOut = null;
14258
+ let rowsStarted = 0;
14259
+ const lastUpload = { current: null };
13903
14260
  const recordImportId = (id) => {
13904
14261
  if (!importIds.includes(id))
13905
14262
  importIds.push(id);
13906
14263
  };
14264
+ const recordNotificationId = (id) => {
14265
+ if (!notificationIds.includes(id))
14266
+ notificationIds.push(id);
14267
+ };
13907
14268
  try {
13908
14269
  for (let i = 0; i < chunks.length; i++) {
13909
14270
  const chunk = chunks[i];
13910
- const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
13911
- if (out.notification_id && !notificationIds.includes(out.notification_id)) {
13912
- notificationIds.push(out.notification_id);
13913
- }
14271
+ rowsStarted += chunk.length;
14272
+ const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId, recordNotificationId, (u) => {
14273
+ lastUpload.current = u;
14274
+ });
13914
14275
  if (!dryRun) {
13915
14276
  reconcileOneChunk(prep, out, matched, notImported);
13916
14277
  }
13917
14278
  }
13918
14279
  } catch (err) {
13919
- if (err?.name === "AbortError") {
14280
+ if (err instanceof ImportPhaseTimeout) {
14281
+ timedOut = err;
14282
+ ctx?.logger?.warn?.(`import-leads: ${err.phase} budget exhausted after ${err.budgetMs}ms; returning status=running importIds=${importIds.join(",")}`);
14283
+ } else if (err?.name === "AbortError") {
13920
14284
  cancelled = true;
13921
14285
  ctx?.logger?.info?.(`import-leads: aborted via signal; importIds=${importIds.join(",")}`);
13922
14286
  } else if (err?.error === true) {
@@ -13931,9 +14295,41 @@ var init_import_leads = __esm({
13931
14295
  throw err;
13932
14296
  }
13933
14297
  }
14298
+ if (timedOut) {
14299
+ if (timedOut.phase === "preprocess" && // A dry run is SUPPOSED to stop after preprocess — committing its
14300
+ // mappings would turn a validation pass into a real import.
14301
+ !dryRun && lastUpload.current && lastUpload.current.importId === timedOut.importId) {
14302
+ resumeParkedUpload(client, lastUpload.current, prep.mappings, ctx);
14303
+ }
14304
+ const rowsPendingUpload = prep.validInputs.length - rowsStarted;
14305
+ const malformed = prep.malformedDomains.map((d) => ({ domain: d, reason: "malformed" }));
14306
+ return {
14307
+ status: "running",
14308
+ timed_out: true,
14309
+ importIds,
14310
+ notification_ids: notificationIds,
14311
+ ...malformed.length > 0 ? { not_imported: malformed } : {},
14312
+ ...dryRun ? { dry_run: true } : {},
14313
+ ...prep.mode === "records" ? { row_ids: prep.validInputs.map((i) => i.rowId) } : {},
14314
+ progress: {
14315
+ phase: timedOut.phase,
14316
+ records_processed: matched.size,
14317
+ records_total: prep.validInputs.length
14318
+ },
14319
+ ...rowsPendingUpload > 0 ? { rows_pending_upload: rowsPendingUpload } : {},
14320
+ region: client.region,
14321
+ _meta: client.lastMeta ?? {
14322
+ region: client.region,
14323
+ endpoint: `GET /imports/${timedOut.importId}`,
14324
+ latency_ms: null,
14325
+ retry_after: null
14326
+ }
14327
+ };
14328
+ }
13934
14329
  return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled, notificationIds);
13935
14330
  }
13936
14331
  };
14332
+ RESUME_COMMIT_BUDGET_MS = 10 * 6e4;
13937
14333
  }
13938
14334
  });
13939
14335
 
@@ -15910,42 +16306,169 @@ var init_like_lead = __esm({
15910
16306
  await client.requestVoid("POST", `/leads/${params.lead_id}/like`);
15911
16307
  return { applied: true, lead_id: params.lead_id, action: "liked" };
15912
16308
  }
15913
- };
16309
+ };
16310
+ }
16311
+ });
16312
+
16313
+ // ../core/dist/tools/dislike-lead.js
16314
+ var dislikeLead;
16315
+ var init_dislike_lead = __esm({
16316
+ "../core/dist/tools/dislike-lead.js"() {
16317
+ "use strict";
16318
+ init_tool_descriptions_generated();
16319
+ dislikeLead = {
16320
+ name: "leadbay_dislike_lead",
16321
+ annotations: {
16322
+ title: "Dislike a lead",
16323
+ readOnlyHint: false,
16324
+ destructiveHint: true,
16325
+ idempotentHint: true,
16326
+ openWorldHint: true
16327
+ },
16328
+ description: leadbay_dislike_lead,
16329
+ optional: true,
16330
+ write: true,
16331
+ inputSchema: {
16332
+ type: "object",
16333
+ properties: {
16334
+ lead_id: {
16335
+ type: "string",
16336
+ description: "UUID of the lead to dislike."
16337
+ }
16338
+ },
16339
+ required: ["lead_id"],
16340
+ additionalProperties: false
16341
+ },
16342
+ execute: async (client, params) => {
16343
+ await client.requestVoid("POST", `/leads/${params.lead_id}/dislike`);
16344
+ return { applied: true, lead_id: params.lead_id, action: "disliked" };
16345
+ }
16346
+ };
16347
+ }
16348
+ });
16349
+
16350
+ // ../core/dist/tools/set-lead-status.js
16351
+ function messageOf(e) {
16352
+ if (e && typeof e === "object" && "message" in e)
16353
+ return String(e.message);
16354
+ return String(e);
16355
+ }
16356
+ async function writeAll(client, leadIds, status, statusDate) {
16357
+ const failed = [];
16358
+ let cursor = 0;
16359
+ async function worker() {
16360
+ for (; ; ) {
16361
+ const i = cursor++;
16362
+ if (i >= leadIds.length)
16363
+ return;
16364
+ const id = leadIds[i];
16365
+ try {
16366
+ await client.requestVoid("POST", statusPath(id), statusBody(status));
16367
+ if (statusDate) {
16368
+ await client.requestVoid("POST", statusDatePath(id), statusDateBody(statusDate));
16369
+ }
16370
+ } catch (e) {
16371
+ failed.push({ lead_id: id, message: messageOf(e) });
16372
+ }
16373
+ }
15914
16374
  }
15915
- });
15916
-
15917
- // ../core/dist/tools/dislike-lead.js
15918
- var dislikeLead;
15919
- var init_dislike_lead = __esm({
15920
- "../core/dist/tools/dislike-lead.js"() {
16375
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, leadIds.length) }, () => worker()));
16376
+ return failed;
16377
+ }
16378
+ var statusPath, statusDatePath, statusBody, statusDateBody, MAX_LEADS, CONCURRENCY, LEAD_STATUS_SET2, SETTABLE_LEAD_STATUSES, ISO_DATE, setLeadStatus;
16379
+ var init_set_lead_status = __esm({
16380
+ "../core/dist/tools/set-lead-status.js"() {
15921
16381
  "use strict";
15922
16382
  init_tool_descriptions_generated();
15923
- dislikeLead = {
15924
- name: "leadbay_dislike_lead",
16383
+ init_import_leads();
16384
+ statusPath = (leadId) => `/leads/${encodeURIComponent(leadId)}/set_status`;
16385
+ statusDatePath = (leadId) => `/leads/${encodeURIComponent(leadId)}/set_status_date`;
16386
+ statusBody = (status) => ({ status });
16387
+ statusDateBody = (date) => ({ date: `${date}T00:00:00Z` });
16388
+ MAX_LEADS = 200;
16389
+ CONCURRENCY = 6;
16390
+ LEAD_STATUS_SET2 = new Set(LEAD_STATUSES);
16391
+ SETTABLE_LEAD_STATUSES = ["WANTED", "WON", "LOST", "UNWANTED"];
16392
+ ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
16393
+ setLeadStatus = {
16394
+ name: "leadbay_set_lead_status",
15925
16395
  annotations: {
15926
- title: "Dislike a lead",
16396
+ title: "Set lead CRM status",
15927
16397
  readOnlyHint: false,
16398
+ // Org-wide and overwrites whatever the last rep set — destructive in the
16399
+ // MCP sense (not reversible from the value we replaced).
15928
16400
  destructiveHint: true,
15929
16401
  idempotentHint: true,
15930
16402
  openWorldHint: true
15931
16403
  },
15932
- description: leadbay_dislike_lead,
16404
+ description: leadbay_set_lead_status,
15933
16405
  optional: true,
15934
16406
  write: true,
15935
16407
  inputSchema: {
15936
16408
  type: "object",
15937
16409
  properties: {
15938
- lead_id: {
16410
+ lead_ids: {
16411
+ type: "array",
16412
+ items: { type: "string" },
16413
+ description: `Lead UUIDs (1-${MAX_LEADS}). Every lead gets the same status.`
16414
+ },
16415
+ status: {
15939
16416
  type: "string",
15940
- description: "UUID of the lead to dislike."
16417
+ description: "One of: WANTED, WON, LOST, UNWANTED (case-insensitive). DEFAULT and INBOUND are accepted but are normally set by Leadbay itself."
16418
+ },
16419
+ status_date: {
16420
+ type: "string",
16421
+ description: "Optional YYYY-MM-DD \u2014 the date the status was actually reached (close date). Omit to let the backend stamp now."
15941
16422
  }
15942
16423
  },
15943
- required: ["lead_id"],
16424
+ required: ["lead_ids", "status"],
15944
16425
  additionalProperties: false
15945
16426
  },
15946
16427
  execute: async (client, params) => {
15947
- await client.requestVoid("POST", `/leads/${params.lead_id}/dislike`);
15948
- return { applied: true, lead_id: params.lead_id, action: "disliked" };
16428
+ const leadIds = (params.lead_ids ?? []).filter((id) => typeof id === "string" && id.trim() !== "");
16429
+ if (leadIds.length === 0) {
16430
+ return {
16431
+ error: true,
16432
+ code: "BAD_INPUT",
16433
+ message: "lead_ids is empty",
16434
+ hint: "Pass at least one lead UUID."
16435
+ };
16436
+ }
16437
+ if (leadIds.length > MAX_LEADS) {
16438
+ return {
16439
+ error: true,
16440
+ code: "BAD_INPUT",
16441
+ message: `lead_ids has ${leadIds.length} entries, max is ${MAX_LEADS}`,
16442
+ hint: `Call leadbay_set_lead_status again per chunk of ${MAX_LEADS} lead_ids or fewer.`
16443
+ };
16444
+ }
16445
+ const status = String(params.status ?? "").trim().toUpperCase();
16446
+ if (!LEAD_STATUS_SET2.has(status)) {
16447
+ return {
16448
+ error: true,
16449
+ code: "BAD_INPUT",
16450
+ message: `Unknown lead status: ${JSON.stringify(params.status)}`,
16451
+ hint: `Use one of ${SETTABLE_LEAD_STATUSES.join(", ")} (case-insensitive).`
16452
+ };
16453
+ }
16454
+ const statusDate = params.status_date?.trim() || void 0;
16455
+ if (statusDate && !ISO_DATE.test(statusDate)) {
16456
+ return {
16457
+ error: true,
16458
+ code: "BAD_INPUT",
16459
+ message: `status_date ${JSON.stringify(params.status_date)} is not YYYY-MM-DD`,
16460
+ hint: "Pass a calendar date like 2026-03-14, or omit it to stamp now."
16461
+ };
16462
+ }
16463
+ const failed = await writeAll(client, leadIds, status, statusDate);
16464
+ const count = leadIds.length - failed.length;
16465
+ return {
16466
+ applied: count > 0,
16467
+ count,
16468
+ status,
16469
+ ...statusDate ? { status_date: statusDate } : {},
16470
+ failed
16471
+ };
15949
16472
  }
15950
16473
  };
15951
16474
  }
@@ -16486,6 +17009,51 @@ var init_prepare_outreach = __esm({
16486
17009
  }
16487
17010
  });
16488
17011
 
17012
+ // ../core/dist/lead-order.js
17013
+ function resolveLeadOrder(raw, tool) {
17014
+ const order = raw?.trim().toUpperCase();
17015
+ if (!order)
17016
+ return {};
17017
+ if (!LEAD_ORDER_SET.has(order)) {
17018
+ return {
17019
+ error: {
17020
+ error: true,
17021
+ code: "BAD_INPUT",
17022
+ message: `Unknown order: ${JSON.stringify(raw)}`,
17023
+ hint: `Call ${tool} again with one of: ${LEAD_ORDERS.join(", ")}.`
17024
+ }
17025
+ };
17026
+ }
17027
+ return { order };
17028
+ }
17029
+ var LEAD_ORDERS, LEAD_ORDER_SET;
17030
+ var init_lead_order = __esm({
17031
+ "../core/dist/lead-order.js"() {
17032
+ "use strict";
17033
+ LEAD_ORDERS = [
17034
+ "SCORE:DESC",
17035
+ "SCORE:ASC",
17036
+ "NAME:ASC",
17037
+ "NAME:DESC",
17038
+ "SIZE:DESC",
17039
+ "SIZE:ASC",
17040
+ "SECTOR:ASC",
17041
+ "SECTOR:DESC",
17042
+ "STATUS:ASC",
17043
+ "STATUS:DESC",
17044
+ "CONTACT_COUNT:DESC",
17045
+ "CONTACT_COUNT:ASC",
17046
+ "LAST_PROSPECTING_ACTION_AT:DESC",
17047
+ "LAST_PROSPECTING_ACTION_AT:ASC",
17048
+ "EPILOGUE_STATUS_SET_AT:DESC",
17049
+ "EPILOGUE_STATUS_SET_AT:ASC",
17050
+ "LIKED:DESC",
17051
+ "DISLIKED:DESC"
17052
+ ];
17053
+ LEAD_ORDER_SET = new Set(LEAD_ORDERS);
17054
+ }
17055
+ });
17056
+
16489
17057
  // ../core/dist/composite/_empty-lens-reason.js
16490
17058
  function criteriaOf(filter) {
16491
17059
  return filter?.lens_filter?.items?.flatMap((i) => i.criteria ?? []) ?? [];
@@ -16522,6 +17090,21 @@ function narrowGeoSentence(narrow) {
16522
17090
  return "";
16523
17091
  return ` Its geography is pinned to ${names.join(", ")} \u2014 a city-scale area or smaller, which on an empty lens is almost always the criterion to relax first.`;
16524
17092
  }
17093
+ async function readAudienceShape(client, lensId) {
17094
+ let filter = null;
17095
+ try {
17096
+ filter = await client.request("GET", `/lenses/${lensId}/filter`);
17097
+ } catch {
17098
+ }
17099
+ const criteria = criteriaOf(filter);
17100
+ const summary = summariseCriteria(criteria);
17101
+ const narrow = narrowLocationsOf(filter, criteria);
17102
+ return {
17103
+ ...summary ? { criteria: summary } : {},
17104
+ ...narrow.length > 0 ? { narrow_locations: narrow } : {},
17105
+ geoSentence: narrowGeoSentence(narrow)
17106
+ };
17107
+ }
16525
17108
  async function diagnoseEmptyLens(client, lensId, computing) {
16526
17109
  if (computing.wishlist || computing.scores) {
16527
17110
  return {
@@ -16536,19 +17119,8 @@ async function diagnoseEmptyLens(client, lensId, computing) {
16536
17119
  row = lenses.find((l) => String(l.id) === String(lensId));
16537
17120
  } catch {
16538
17121
  }
16539
- let filter = null;
16540
- try {
16541
- filter = await client.request("GET", `/lenses/${lensId}/filter`);
16542
- } catch {
16543
- }
16544
- const criteria = criteriaOf(filter);
16545
- const summary = summariseCriteria(criteria);
16546
- const narrow = narrowLocationsOf(filter, criteria);
16547
- const geo = narrowGeoSentence(narrow);
16548
- const extras = {
16549
- ...summary ? { criteria: summary } : {},
16550
- ...narrow.length > 0 ? { narrow_locations: narrow } : {}
16551
- };
17122
+ const { geoSentence: geo, ...extras } = await readAudienceShape(client, lensId);
17123
+ const summary = extras.criteria;
16552
17124
  if (row?.not_enough_lead_candidates) {
16553
17125
  return {
16554
17126
  code: "no_candidates",
@@ -16667,6 +17239,7 @@ var init_pull_leads = __esm({
16667
17239
  "../core/dist/composite/pull-leads.js"() {
16668
17240
  "use strict";
16669
17241
  init_agent_memory();
17242
+ init_lead_order();
16670
17243
  init_empty_lens_reason();
16671
17244
  init_tool_descriptions_generated();
16672
17245
  pullLeads = {
@@ -16688,6 +17261,10 @@ var init_pull_leads = __esm({
16688
17261
  },
16689
17262
  count: { type: "number", description: "Leads per page, max 50 (default 20)" },
16690
17263
  page: { type: "number", description: "Page number, 0-indexed (default 0)" },
17264
+ order: {
17265
+ type: "string",
17266
+ description: "Optional sort, FIELD:ASC|DESC (SCORE, NAME, SIZE, SECTOR, STATUS, CONTACT_COUNT, LAST_PROSPECTING_ACTION_AT, LIKED). Omit for the lens's own Discover ranking. An unknown value is rejected and the error lists every accepted order."
17267
+ },
16691
17268
  verbose: {
16692
17269
  type: "boolean",
16693
17270
  description: "If true, include the full set of lead-summary fields. Default false: returns the trimmed agent-friendly form."
@@ -16796,7 +17373,11 @@ var init_pull_leads = __esm({
16796
17373
  const page = params.page ?? 0;
16797
17374
  const count = Math.min(params.count ?? 20, 50);
16798
17375
  const verbose = params.verbose ?? false;
16799
- const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true`);
17376
+ const resolvedOrder = resolveLeadOrder(params.order, "leadbay_pull_leads");
17377
+ if (resolvedOrder.error)
17378
+ return resolvedOrder.error;
17379
+ const orderQs = resolvedOrder.order ? `&order=${encodeURIComponent(resolvedOrder.order)}` : "";
17380
+ const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true${orderQs}`);
16800
17381
  const summaries = await Promise.all(res.items.map(async (lead) => {
16801
17382
  try {
16802
17383
  const r = await client.request("GET", `/leads/${lead.id}/ai_agent_responses`);
@@ -17049,6 +17630,7 @@ var init_pull_followups = __esm({
17049
17630
  "../core/dist/composite/pull-followups.js"() {
17050
17631
  "use strict";
17051
17632
  init_agent_memory();
17633
+ init_lead_order();
17052
17634
  init_tool_descriptions_generated();
17053
17635
  init_geo_helpers();
17054
17636
  init_country_guard();
@@ -17081,6 +17663,10 @@ var init_pull_followups = __esm({
17081
17663
  type: "number",
17082
17664
  description: "Leads per page, max 200 (default 20)."
17083
17665
  },
17666
+ order: {
17667
+ type: "string",
17668
+ description: "Optional sort, FIELD:ASC|DESC (SCORE, NAME, SIZE, SECTOR, STATUS, CONTACT_COUNT, LAST_PROSPECTING_ACTION_AT, LIKED). Omit for the Monitor's own ranking. An unknown value is rejected and the error lists every accepted order."
17669
+ },
17084
17670
  page: {
17085
17671
  type: "number",
17086
17672
  description: "Page number, 0-indexed (default 0)."
@@ -17190,7 +17776,7 @@ var init_pull_followups = __esm({
17190
17776
  if (params.city_id)
17191
17777
  geoTexts.push(params.city_id);
17192
17778
  if (geoTexts.length > 0) {
17193
- const { resolved, ambiguities } = await resolveLocations(client, geoTexts);
17779
+ const { resolved: resolved2, ambiguities } = await resolveLocations(client, geoTexts);
17194
17780
  if (ambiguities.length > 0) {
17195
17781
  return withAgentMemoryMeta(client, {
17196
17782
  status: "ambiguous_locations",
@@ -17205,8 +17791,8 @@ var init_pull_followups = __esm({
17205
17791
  }
17206
17792
  }, ctx);
17207
17793
  }
17208
- if (resolved.length > 0) {
17209
- effectiveSetFilter = mergeLocationIds(effectiveSetFilter, resolved);
17794
+ if (resolved2.length > 0) {
17795
+ effectiveSetFilter = mergeLocationIds(effectiveSetFilter, resolved2);
17210
17796
  }
17211
17797
  }
17212
17798
  if (effectiveSetFilter) {
@@ -17216,12 +17802,17 @@ var init_pull_followups = __esm({
17216
17802
  ctx?.logger?.warn?.(`pull_followups: POST /monitor/filter failed: ${err?.message ?? err?.code ?? err}`);
17217
17803
  }
17218
17804
  }
17805
+ const resolved = resolveLeadOrder(params.order, "leadbay_pull_followups");
17806
+ if (resolved.error)
17807
+ return resolved.error;
17808
+ const order = resolved.order;
17219
17809
  const qs = new URLSearchParams({
17220
17810
  personal: String(personal),
17221
17811
  liked: String(liked),
17222
17812
  filtered: String(filtered),
17223
17813
  count: String(count),
17224
- page: String(page)
17814
+ page: String(page),
17815
+ ...order ? { order } : {}
17225
17816
  }).toString();
17226
17817
  const [filterR, monitorR] = await Promise.allSettled([
17227
17818
  filtered ? client.request("GET", "/monitor/filter") : Promise.resolve(null),
@@ -18500,7 +19091,7 @@ var init_research_lead_by_id = __esm({
18500
19091
  },
18501
19092
  _meta: {
18502
19093
  type: "object",
18503
- description: "Operator context: region (us/fr/custom), lens_id (the lens used for the lead-by-id fetch), web_fetch_in_progress (true if the backend is still hydrating signals), has_reachable_contact (true if at least one contact or recommended_contact has email or phone \u2014 drives NEXT STEPS routing between enrichment vs outreach). When the call was routed via leadbay_research_lead_by_name_fuzzy, also: resolved_from='companyName', resolved_query='<needle>', match_candidates=[{leadId,name,score}].",
19094
+ description: "Operator context: region (us/fr/custom), lens_id (the lens used for the lead-by-id fetch), web_fetch_in_progress (true if the backend is still hydrating signals), has_reachable_contact (true if at least one contact or recommended_contact has email or phone \u2014 drives NEXT STEPS routing between enrichment vs outreach). When the call was routed via leadbay_research_lead_by_name_fuzzy, also: resolved_from='companyName' (matched in the user's own leads) or 'resolver' (matched in the Leadbay company registry), resolved_query='<needle>', resolved_matched_on=['website_exact',\u2026] on the resolver path, match_candidates=[{leadId,name,score}].",
18504
19095
  properties: {
18505
19096
  region: { type: "string" },
18506
19097
  lens_id: { type: "number" },
@@ -18512,6 +19103,10 @@ var init_research_lead_by_id = __esm({
18512
19103
  type: ["array", "null"],
18513
19104
  items: { type: "object" }
18514
19105
  },
19106
+ resolved_matched_on: {
19107
+ type: ["array", "null"],
19108
+ items: { type: "string" }
19109
+ },
18515
19110
  agent_memory: { type: "object" }
18516
19111
  },
18517
19112
  // _meta is an open envelope: the MCP server layer injects
@@ -18684,7 +19279,8 @@ var init_research_lead_by_id = __esm({
18684
19279
  has_reachable_contact: hasReachableContact,
18685
19280
  resolved_from: params._resolved?.from ?? null,
18686
19281
  resolved_query: params._resolved?.query ?? null,
18687
- match_candidates: params._resolved?.candidates ?? null
19282
+ match_candidates: params._resolved?.candidates ?? null,
19283
+ resolved_matched_on: params._resolved?.matched_on ?? null
18688
19284
  }
18689
19285
  }, _ctx);
18690
19286
  }
@@ -18708,16 +19304,6 @@ var init_research_lead_by_id = __esm({
18708
19304
  });
18709
19305
 
18710
19306
  // ../core/dist/composite/research-lead-by-name-fuzzy.js
18711
- function rankSubstringMatches(needle, candidates) {
18712
- const n = needle.toLowerCase();
18713
- const hits = candidates.filter((c) => typeof c.name === "string" && c.name.toLowerCase().includes(n));
18714
- hits.sort((a, b) => {
18715
- const aScore = a.score ?? -Infinity;
18716
- const bScore = b.score ?? -Infinity;
18717
- return bScore - aScore;
18718
- });
18719
- return hits;
18720
- }
18721
19307
  function parseLensId(value) {
18722
19308
  const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
18723
19309
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
@@ -18732,6 +19318,34 @@ function suggestionLeadId(suggestion) {
18732
19318
  function isLeadbayError(error) {
18733
19319
  return typeof error === "object" && error !== null && error.error === true && typeof error.code === "string" && typeof error.message === "string" && typeof error.hint === "string";
18734
19320
  }
19321
+ function businessDomainFromEmail(email) {
19322
+ if (!email || typeof email !== "string")
19323
+ return null;
19324
+ const at = email.lastIndexOf("@");
19325
+ if (at === -1)
19326
+ return null;
19327
+ const domain = normalizeDomain(email.slice(at + 1));
19328
+ if (!domain)
19329
+ return null;
19330
+ return PUBLIC_MAILBOX_DOMAINS.has(domain) ? null : domain;
19331
+ }
19332
+ function buildResolvePayload(params) {
19333
+ const queryDomain = normalizeDomain(params.query);
19334
+ const website = (params.website ? normalizeDomain(params.website) : null) ?? queryDomain ?? businessDomainFromEmail(params.email);
19335
+ const payload = {};
19336
+ if (!queryDomain)
19337
+ payload.name = params.query;
19338
+ if (website)
19339
+ payload.website = website;
19340
+ if (params.email)
19341
+ payload.email = params.email;
19342
+ if (params.registry_number)
19343
+ payload.registry_number = params.registry_number;
19344
+ return payload;
19345
+ }
19346
+ function hasStrongIdentityKey(payload) {
19347
+ return Boolean(payload.website || payload.registry_number);
19348
+ }
18735
19349
  async function resolveWithinLens(client, query, lensId) {
18736
19350
  const results = await client.request("GET", `/lenses/${lensId}/leads/wishlist?q=${encodeURIComponent(query)}&count=50&page=0&contacts=false`);
18737
19351
  return results.items.map((lead) => ({
@@ -18753,12 +19367,33 @@ async function resolveAcrossVisibleCorpus(client, query) {
18753
19367
  };
18754
19368
  }).filter((suggestion) => suggestion.id !== "" && suggestion.name !== "");
18755
19369
  }
18756
- var researchLeadByNameFuzzy;
19370
+ async function hydrateAmbiguous(client, candidates, lensId) {
19371
+ const selected = candidates.slice(0, MAX_AMBIGUOUS_CANDIDATES);
19372
+ const settled = await Promise.allSettled(selected.map((c) => client.request("GET", `/lenses/${lensId}/leads/${c.lead_id}`)));
19373
+ return selected.map((c, i) => {
19374
+ const r = settled[i];
19375
+ const lead = r.status === "fulfilled" ? r.value : null;
19376
+ return {
19377
+ leadId: c.lead_id,
19378
+ name: lead?.name ?? null,
19379
+ website: lead?.website ?? null,
19380
+ location: lead?.location?.full ?? lead?.location?.city ?? lead?.location?.country ?? null,
19381
+ registry_ids: lead?.registry_ids ?? null,
19382
+ score: c.score,
19383
+ matched_on: c.matched_on,
19384
+ lead_fields_populated: c.lead_fields_populated
19385
+ };
19386
+ });
19387
+ }
19388
+ var RESOLVE_TIMEOUT_MS, MAX_AMBIGUOUS_CANDIDATES, researchLeadByNameFuzzy;
18757
19389
  var init_research_lead_by_name_fuzzy = __esm({
18758
19390
  "../core/dist/composite/research-lead-by-name-fuzzy.js"() {
18759
19391
  "use strict";
18760
19392
  init_research_lead_by_id();
19393
+ init_import_leads();
18761
19394
  init_tool_descriptions_generated();
19395
+ RESOLVE_TIMEOUT_MS = 1e4;
19396
+ MAX_AMBIGUOUS_CANDIDATES = 4;
18762
19397
  researchLeadByNameFuzzy = {
18763
19398
  name: "leadbay_research_lead_by_name_fuzzy",
18764
19399
  annotations: {
@@ -18774,11 +19409,23 @@ var init_research_lead_by_name_fuzzy = __esm({
18774
19409
  properties: {
18775
19410
  companyName: {
18776
19411
  type: "string",
18777
- description: "Company name, domain, or contact name to resolve across visible Leadbay leads in Discover, Monitor, and Activate."
19412
+ description: "Company name, domain, or contact name. Resolved against the user's own Discover/Monitor/Activate leads and the Leadbay company registry."
19413
+ },
19414
+ website: {
19415
+ type: "string",
19416
+ description: "Company domain or website when you have one (`acme.com`, `https://www.acme.com/` \u2014 both fine). This is the single strongest match key; pass it whenever the user mentioned a domain, and a company outside their leads becomes findable."
19417
+ },
19418
+ email: {
19419
+ type: "string",
19420
+ description: "A contact email at the company. Used to derive the company domain when `website` is absent; consumer mailboxes (gmail, orange.fr, \u2026) are ignored."
19421
+ },
19422
+ registry_number: {
19423
+ type: "string",
19424
+ description: "Company registry number (SIREN/SIRET in France, company number elsewhere). The other exact match key \u2014 pass it when the user supplies one, or when a previous LEAD_NOT_FOUND hint asked for it."
18778
19425
  },
18779
19426
  lensId: {
18780
19427
  type: "number",
18781
- description: "Optional strict scope. When supplied, search only this lens's wishlist; normally omit to search all visible Leadbay leads."
19428
+ description: "Optional strict scope. When supplied, search only this lens's wishlist and do NOT fall through to the registry; normally omit."
18782
19429
  },
18783
19430
  concise: {
18784
19431
  type: "boolean",
@@ -18794,59 +19441,123 @@ var init_research_lead_by_name_fuzzy = __esm({
18794
19441
  additionalProperties: false
18795
19442
  },
18796
19443
  // Output shape matches leadbay_research_lead_by_id; the only additions are
18797
- // _meta.resolved_from / resolved_query / match_candidates which are
18798
- // documented on _by_id's output schema. Defer to _by_id for the schema —
18799
- // duplicating it would just rot.
19444
+ // _meta.resolved_from / resolved_query / resolved_matched_on /
19445
+ // match_candidates which are documented on _by_id's output schema. Defer to
19446
+ // _by_id for the schema — duplicating it would just rot. The one exception
19447
+ // is the ambiguous branch, which returns a disambiguation payload instead
19448
+ // of a research card.
18800
19449
  outputSchema: {
18801
19450
  type: "object",
18802
- description: "Same shape as leadbay_research_lead_by_id, with _meta.resolved_from='companyName', _meta.resolved_query='<needle>', and _meta.match_candidates=[{leadId,name,score}] populated.",
19451
+ description: "Same shape as leadbay_research_lead_by_id, with _meta.resolved_from='companyName'|'resolver', _meta.resolved_query='<needle>', _meta.resolved_matched_on=[...], and _meta.match_candidates=[{leadId,name,score}] populated. When the registry resolver cannot pick one company, returns {resolution:'ambiguous', query, candidates:[{leadId,name,website,location,registry_ids,score,matched_on}]} instead \u2014 ask the user which one, then call leadbay_research_lead_by_id.",
18803
19452
  additionalProperties: true
18804
19453
  },
18805
19454
  execute: async (client, params, ctx) => {
18806
19455
  if (!params.companyName || typeof params.companyName !== "string" || params.companyName.trim() === "") {
18807
- throw client.makeError("INVALID_PARAMS", "companyName is required", "Pass the company name as a string. If you already have the lead UUID, call leadbay_research_lead_by_id directly.");
19456
+ throw client.makeError("INVALID_PARAMS", "companyName is required and must be a non-empty string", "Pass the company name, domain, or contact name as `companyName` \u2014 e.g. companyName:'Wink Lab'. Add `website` (the strongest match key) or `email` when you have one. If you already have the lead UUID, call leadbay_research_lead_by_id with leadId instead.");
18808
19457
  }
18809
19458
  const query = params.companyName.trim();
18810
- let ranked;
18811
19459
  let lensId = params.lensId;
18812
- let usedActiveLensFallback = false;
18813
- if (lensId !== void 0) {
18814
- ranked = await resolveWithinLens(client, query, lensId);
18815
- } else {
19460
+ if (params.lensId !== void 0) {
19461
+ const scoped = await resolveWithinLens(client, query, params.lensId);
19462
+ if (scoped.length > 0) {
19463
+ return await delegate(scoped, params.lensId);
19464
+ }
19465
+ throw client.makeError("LEAD_NOT_FOUND", `No lead matching "${query}" in lens ${params.lensId}`, "This lookup was intentionally restricted to the supplied lens. Omit lensId to search your visible leads across Discover, Monitor, and Activate and then the Leadbay company registry.");
19466
+ }
19467
+ async function delegate(matches, fallbackLens) {
19468
+ const [primary, ...rest] = matches;
19469
+ const resolvedLens = primary.lensId ?? fallbackLens ?? await client.resolveDefaultLens();
19470
+ return await researchLeadById.execute(client, {
19471
+ leadId: primary.id,
19472
+ lensId: resolvedLens,
19473
+ concise: params.concise,
19474
+ response_format: params.response_format,
19475
+ _resolved: {
19476
+ from: "companyName",
19477
+ query,
19478
+ candidates: rest.slice(0, MAX_AMBIGUOUS_CANDIDATES).map((m) => ({
19479
+ leadId: m.id,
19480
+ name: m.name,
19481
+ score: m.score
19482
+ }))
19483
+ }
19484
+ }, ctx);
19485
+ }
19486
+ const payload = buildResolvePayload({
19487
+ query,
19488
+ website: params.website,
19489
+ email: params.email,
19490
+ registry_number: params.registry_number
19491
+ });
19492
+ let corpusSearched = false;
19493
+ let ranked = [];
19494
+ const searchCorpus = async (strict) => {
18816
19495
  try {
18817
19496
  ranked = await resolveAcrossVisibleCorpus(client, query);
19497
+ corpusSearched = true;
18818
19498
  } catch (error) {
18819
- if (isLeadbayError(error))
19499
+ if (strict && isLeadbayError(error))
18820
19500
  throw error;
18821
- lensId = await client.resolveDefaultLens();
18822
- usedActiveLensFallback = true;
18823
- ctx?.logger?.warn?.("Cross-tab company search was unavailable; falling back to the active lens for this lookup.");
18824
- ranked = rankSubstringMatches(query, await resolveWithinLens(client, query, lensId));
18825
- }
18826
- }
18827
- if (ranked.length === 0) {
18828
- const scope = usedActiveLensFallback ? `in active lens ${lensId}; cross-tab search was unavailable` : params.lensId === void 0 ? "across your visible Leadbay leads" : `in lens ${params.lensId}`;
18829
- const hint = usedActiveLensFallback ? `Only active lens ${lensId} was checked because cross-tab search was unavailable. Retry later before concluding the company is missing or offering to import it.` : params.lensId === void 0 ? "Search checks company names, domains, and contact names across Discover, Monitor, and Activate. Confirm the spelling or domain, or add/import the company first." : "This lookup was intentionally restricted to the supplied lens. Omit lensId to search visible leads across Discover, Monitor, and Activate.";
18830
- throw client.makeError("LEAD_NOT_FOUND", `No lead matching "${query}" ${scope}`, hint);
18831
- }
18832
- const [primary, ...rest] = ranked;
18833
- lensId = primary.lensId ?? lensId ?? await client.resolveDefaultLens();
18834
- const candidates = rest.slice(0, 4).map((m) => ({
18835
- leadId: m.id,
18836
- name: m.name,
18837
- score: m.score
18838
- }));
18839
- return await researchLeadById.execute(client, {
18840
- leadId: primary.id,
18841
- lensId,
18842
- concise: params.concise,
18843
- response_format: params.response_format,
18844
- _resolved: {
18845
- from: "companyName",
18846
- query,
18847
- candidates
19501
+ ctx?.logger?.warn?.("Cross-tab company search was unavailable; resolving against the Leadbay registry instead.");
18848
19502
  }
18849
- }, ctx);
19503
+ };
19504
+ const registryFirst = hasStrongIdentityKey(payload);
19505
+ if (!registryFirst) {
19506
+ await searchCorpus(true);
19507
+ if (ranked.length > 0)
19508
+ return await delegate(ranked);
19509
+ }
19510
+ let resolved;
19511
+ try {
19512
+ resolved = await client.request("POST", "/leads/resolve", payload, { timeoutMs: RESOLVE_TIMEOUT_MS });
19513
+ } catch (error) {
19514
+ if (isLeadbayError(error))
19515
+ throw error;
19516
+ throw client.makeError("LEAD_NOT_FOUND", `Could not reach the Leadbay company registry while looking up "${query}"`, "The registry lookup did not complete. Retry once; if it fails again, say so rather than concluding the company is missing.", "POST /leads/resolve");
19517
+ }
19518
+ if (resolved.type === "matched") {
19519
+ lensId = lensId ?? await client.resolveDefaultLens();
19520
+ return await researchLeadById.execute(client, {
19521
+ leadId: resolved.lead_id,
19522
+ lensId,
19523
+ concise: params.concise,
19524
+ response_format: params.response_format,
19525
+ _resolved: {
19526
+ from: "resolver",
19527
+ query,
19528
+ candidates: [],
19529
+ matched_on: resolved.matched_on
19530
+ }
19531
+ }, ctx);
19532
+ }
19533
+ if (resolved.type === "ambiguous" && resolved.candidates.length > 0) {
19534
+ const hydrationLens = lensId ?? await client.resolveDefaultLens();
19535
+ const candidates = await hydrateAmbiguous(client, resolved.candidates, hydrationLens);
19536
+ return {
19537
+ resolution: "ambiguous",
19538
+ query,
19539
+ resolver_payload: payload,
19540
+ candidates,
19541
+ next_step: "Ask the user which company they mean, then call leadbay_research_lead_by_id with the chosen leadId. Do not guess from score \u2014 it is a tied evidence band, not a confidence.",
19542
+ _meta: {
19543
+ region: client.region,
19544
+ lens_id: hydrationLens,
19545
+ resolved_from: "resolver",
19546
+ resolved_query: query
19547
+ }
19548
+ };
19549
+ }
19550
+ if (!corpusSearched) {
19551
+ await searchCorpus(false);
19552
+ if (ranked.length > 0)
19553
+ return await delegate(ranked);
19554
+ }
19555
+ const registryScope = payload.website ? `the Leadbay company registry (domain ${payload.website})` : "the Leadbay company registry";
19556
+ const searched = corpusSearched ? `in your visible Leadbay leads and in ${registryScope}` : `in ${registryScope} \u2014 your own leads could NOT be searched, the search route was unreachable`;
19557
+ const wanted = resolved.type === "none" && resolved.would_help.length > 0 ? resolved.would_help : ["website", "registry_number"];
19558
+ const asks = wanted.map((f) => f === "registry_number" ? "a registry number (SIREN/SIRET) for `registry_number`" : f === "website" ? "the company website for `website`" : `\`${f}\``).join(" or ");
19559
+ const hint = resolved.type === "unidentifiable" ? `The registry could not identify a company from this input (${resolved.reason}). Ask the user for ${asks}, then call this tool again with it.` : `The registry found no company for what was supplied. It would match on ${asks}. Ask the user for that \u2014 "what's their website?" usually settles it \u2014 then call this tool again. Do not offer an import before asking.`;
19560
+ throw client.makeError("LEAD_NOT_FOUND", `No company matching "${query}" ${searched}`, hint, "POST /leads/resolve");
18850
19561
  }
18851
19562
  };
18852
19563
  }
@@ -20926,7 +21637,7 @@ async function runPreview(client, params, ctx, perPhaseBudget, _totalBudget) {
20926
21637
  throw Object.assign(new Error("aborted"), { name: "AbortError" });
20927
21638
  }
20928
21639
  if (!fileImport) {
20929
- throw client.makeError("IMPORT_BUDGET_EXHAUSTED", `Preview preprocess did not finish within ${perPhaseBudget}ms`, "Increase per_phase_budget_ms or shrink the input. The wizard row will eventually be cleaned up.", `GET /imports/${importId}`);
21640
+ throw client.makeError("IMPORT_TIMEOUT", `Preview preprocess did not finish within ${perPhaseBudget}ms`, `Raise per_phase_budget_ms above ${perPhaseBudget} or send fewer rows \u2014 re-running the identical call will not help. The wizard row will eventually be cleaned up.`, `GET /imports/${importId}`);
20930
21641
  }
20931
21642
  if (fileImport.pre_processing?.error) {
20932
21643
  throw client.makeError("IMPORT_PREPROCESS_FAILED", `Preview preprocess failed: ${fileImport.pre_processing.error}`, "Inspect the input rows for encoding / shape issues.", `GET /imports/${importId}`);
@@ -21093,6 +21804,19 @@ var init_import_and_qualify = __esm({
21093
21804
  type: "string",
21094
21805
  description: "Import handle to pass to leadbay_import_status when wait_for_completion=false."
21095
21806
  },
21807
+ timed_out: {
21808
+ type: "boolean",
21809
+ description: "True when the underlying import ran out of poll budget. Still running server-side \u2014 poll leadbay_import_status(import_ids). Do NOT re-issue the import."
21810
+ },
21811
+ rows_pending_upload: {
21812
+ type: "number",
21813
+ description: "Rows from later chunks that never reached the backend. These are NOT running anywhere; re-import just those rows."
21814
+ },
21815
+ row_ids: {
21816
+ type: "array",
21817
+ description: "Records mode only: the synthetic row id of each input row, in the order you passed `records[]`. leadbay_import_status reports recovered leads by that id \u2014 use this to map them back to your source rows.",
21818
+ items: { type: "string" }
21819
+ },
21096
21820
  // preview-shape keys
21097
21821
  mapping_hints: {
21098
21822
  type: "array",
@@ -21236,7 +21960,7 @@ var init_import_and_qualify = __esm({
21236
21960
  return {
21237
21961
  kind: "result",
21238
21962
  status: "running",
21239
- handle_id: queued.handle_id,
21963
+ ...queued.handle_id ? { handle_id: queued.handle_id } : {},
21240
21964
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
21241
21965
  qualify_id: null,
21242
21966
  import_ids: queued.importIds,
@@ -21268,7 +21992,35 @@ var init_import_and_qualify = __esm({
21268
21992
  wait_for_completion: true
21269
21993
  }, ctx);
21270
21994
  if (isImportLeadsRunningResult(importResultRaw)) {
21271
- 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");
21995
+ return {
21996
+ kind: "result",
21997
+ status: "running",
21998
+ ...importResultRaw.handle_id ? { handle_id: importResultRaw.handle_id } : {},
21999
+ // Everything the rendering contract keys off has to survive the
22000
+ // wrapper. Without `timed_out` the agent can't tell this from a
22001
+ // deliberate async launch; without `rows_pending_upload` a >100-row
22002
+ // batch silently loses every unuploaded chunk; without `dry_run`
22003
+ // leadbay_import_status can't tell a validation pass from a real
22004
+ // import still committing.
22005
+ ...importResultRaw.timed_out ? { timed_out: true } : {},
22006
+ ...importResultRaw.rows_pending_upload !== void 0 ? { rows_pending_upload: importResultRaw.rows_pending_upload } : {},
22007
+ ...importResultRaw.dry_run ? { dry_run: true } : {},
22008
+ ...importResultRaw.row_ids ? { row_ids: importResultRaw.row_ids } : {},
22009
+ ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
22010
+ qualify_id: null,
22011
+ import_ids: importResultRaw.importIds,
22012
+ notification_ids: importResultRaw.notification_ids ?? [],
22013
+ imported: [],
22014
+ not_imported: (importResultRaw.not_imported ?? []).map(toNotImportedEntry),
22015
+ qualified: [],
22016
+ still_running: [],
22017
+ failed: [],
22018
+ quota_exceeded: false,
22019
+ skipped_already_qualified: [],
22020
+ not_in_lens: [],
22021
+ region: client.region,
22022
+ _meta: importResultRaw._meta
22023
+ };
21272
22024
  }
21273
22025
  const importResult = importResultRaw;
21274
22026
  if (importResult.cancelled) {
@@ -22129,12 +22881,80 @@ function summarizeImports(imports, dryRun) {
22129
22881
  records_total: recordsTotal
22130
22882
  };
22131
22883
  }
22132
- var importStatus;
22884
+ function isInProgress(err) {
22885
+ return /in_progress/i.test(String(err?.message ?? ""));
22886
+ }
22887
+ async function fetchReconciledRecords(client, importIds, declaredTotal, ctx) {
22888
+ const canonicalLeadIds = /* @__PURE__ */ new Set();
22889
+ for (const importId of importIds) {
22890
+ try {
22891
+ const res = await client.request("GET", `/imports/${importId}/leads`);
22892
+ for (const id of res?.lead_ids ?? [])
22893
+ canonicalLeadIds.add(id);
22894
+ } catch (err) {
22895
+ if (isInProgress(err))
22896
+ throw new ImportNotReady();
22897
+ if (err?.code !== "NOT_FOUND" && err?._meta?.http_status !== 404)
22898
+ throw err;
22899
+ ctx?.logger?.warn?.(`import-status: /imports/${importId}/leads not available on this backend (404) \u2014 using records only`);
22900
+ }
22901
+ }
22902
+ const all = [];
22903
+ for (const importId of importIds) {
22904
+ for (let page = 0; page < RECORDS_MAX_PAGES; page++) {
22905
+ const qs = `count=${RECORDS_PAGE_SIZE}&page=${page}&automatic_match=true&manual_match=true&no_match=true&matching=true&importing=true&imported=true`;
22906
+ let res;
22907
+ try {
22908
+ res = await client.request("GET", `/imports/${importId}/records?${qs}`);
22909
+ } catch (err) {
22910
+ if (isInProgress(err))
22911
+ throw new ImportNotReady();
22912
+ throw err;
22913
+ }
22914
+ all.push(...res.items);
22915
+ const totalPages = res.pagination.pages ?? 0;
22916
+ if (page + 1 >= totalPages)
22917
+ break;
22918
+ if (page + 1 === RECORDS_MAX_PAGES) {
22919
+ ctx?.logger?.warn?.(`import-status: importId=${importId} has >${RECORDS_MAX_PAGES} record pages; skipping reconciliation`);
22920
+ return null;
22921
+ }
22922
+ }
22923
+ }
22924
+ const { leads, not_imported, pending, distinct, pendingLeadIds } = reconcileRecords(all);
22925
+ const deficit = settlingDeficit(declaredTotal, distinct);
22926
+ const seenLeadIds = new Set(leads.map((l) => l.leadId));
22927
+ const merged = [...leads];
22928
+ if (deficit === 0) {
22929
+ for (const id of canonicalLeadIds) {
22930
+ if (seenLeadIds.has(id))
22931
+ continue;
22932
+ if (pendingLeadIds.has(id))
22933
+ continue;
22934
+ merged.push({ leadId: id, name: null });
22935
+ }
22936
+ }
22937
+ return {
22938
+ leads: merged,
22939
+ not_imported,
22940
+ // A snapshot short of the declared row count is not final — see
22941
+ // `settlingDeficit`. Measured on DISTINCT rows: `all.length` counts a
22942
+ // re-paged row twice and would mask a genuine shortfall.
22943
+ still_settling: pending + deficit
22944
+ };
22945
+ }
22946
+ var RECORDS_PAGE_SIZE, RECORDS_MAX_PAGES, ImportNotReady, importStatus;
22133
22947
  var init_import_status = __esm({
22134
22948
  "../core/dist/composite/import-status.js"() {
22135
22949
  "use strict";
22136
22950
  init_bulk_store();
22951
+ init_import_records();
22952
+ init_import_commit_log();
22137
22953
  init_tool_descriptions_generated();
22954
+ RECORDS_PAGE_SIZE = 100;
22955
+ RECORDS_MAX_PAGES = 20;
22956
+ ImportNotReady = class extends Error {
22957
+ };
22138
22958
  importStatus = {
22139
22959
  name: "leadbay_import_status",
22140
22960
  annotations: {
@@ -22154,8 +22974,12 @@ var init_import_status = __esm({
22154
22974
  },
22155
22975
  importIds: {
22156
22976
  type: "array",
22157
- description: "Legacy backend file-import ids to inspect directly.",
22977
+ description: "Backend file-import ids to inspect directly \u2014 from a completed import's `importIds`, or from a `{status:'running', timed_out:true}` result.",
22158
22978
  items: { type: "string" }
22979
+ },
22980
+ dry_run: {
22981
+ type: "boolean",
22982
+ description: "Pass true when the importIds came from a dry run. A dry run and an import still committing its mappings look identical on the wire, so without this the tool reports the dry run as still running rather than risk rendering it as a real import."
22159
22983
  }
22160
22984
  },
22161
22985
  additionalProperties: false
@@ -22169,9 +22993,13 @@ var init_import_status = __esm({
22169
22993
  progress: { type: "object" },
22170
22994
  result: {
22171
22995
  type: "object",
22172
- description: "Final import result when the handle has completed in this MCP instance."
22996
+ description: "Final import result: {leads, not_imported, importIds, still_settling?}. Present when a handle_id resolves a completed run in this MCP instance, OR when the importIds[] path finds every import complete and reconciles the wizard's records."
22173
22997
  },
22174
22998
  error: { type: "string" },
22999
+ dry_run: {
23000
+ type: "boolean",
23001
+ description: "True when these importIds came from a dry run. NOTHING was committed to the CRM \u2014 render it as a validation pass, never as a completed import."
23002
+ },
22175
23003
  region: { type: "string" },
22176
23004
  _meta: { type: "object" }
22177
23005
  },
@@ -22180,7 +23008,7 @@ var init_import_status = __esm({
22180
23008
  execute: async (client, params, ctx) => {
22181
23009
  let handleId = params.handle_id;
22182
23010
  let importIds = params.importIds ?? [];
22183
- let handleDryRun;
23011
+ let handleDryRun = params.dry_run;
22184
23012
  if (handleId) {
22185
23013
  if (!isValidBulkId(handleId)) {
22186
23014
  throw client.makeError("BULK_INVALID_ID", "handle_id is not a valid UUIDv4", "Pass the handle_id returned by leadbay_import_leads verbatim.", "");
@@ -22197,7 +23025,7 @@ var init_import_status = __esm({
22197
23025
  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.", "");
22198
23026
  }
22199
23027
  importIds = record.import_ids;
22200
- handleDryRun = record.dry_run;
23028
+ handleDryRun = record.dry_run ?? handleDryRun;
22201
23029
  if (record.status === "complete" && record.result) {
22202
23030
  return {
22203
23031
  status: "complete",
@@ -22258,6 +23086,7 @@ var init_import_status = __esm({
22258
23086
  };
22259
23087
  }
22260
23088
  }
23089
+ importIds = [...new Set(importIds)];
22261
23090
  if (importIds.length === 0) {
22262
23091
  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.", "");
22263
23092
  }
@@ -22273,14 +23102,40 @@ var init_import_status = __esm({
22273
23102
  return Boolean(i.processing?.finished);
22274
23103
  return Boolean(i.processing?.finished || i.pre_processing?.finished && !i.processing);
22275
23104
  });
23105
+ let reconciled = null;
23106
+ const commitError = commitFailureFor(importIds);
23107
+ let notReady = false;
23108
+ const declaredTotal = imports.reduce((n, i) => n + Number(i.total_records ?? 0), 0);
23109
+ if (!failed && complete && handleDryRun !== true && importIds.length > 0) {
23110
+ try {
23111
+ reconciled = await fetchReconciledRecords(client, importIds, declaredTotal, ctx);
23112
+ } catch (err) {
23113
+ if (err instanceof ImportNotReady) {
23114
+ notReady = true;
23115
+ ctx?.logger?.info?.(`import-status: wizard reports in_progress; mappings not committed yet \u2014 reporting running`);
23116
+ } else {
23117
+ ctx?.logger?.warn?.(`import-status: records reconciliation failed (${err?.code ?? err?.message ?? "unknown"}); returning status only`);
23118
+ }
23119
+ }
23120
+ }
23121
+ const settled = complete && !notReady;
22276
23122
  return {
22277
- status: failed ? "failed" : complete ? "complete" : "running",
23123
+ status: failed || commitError ? "failed" : settled ? "complete" : "running",
22278
23124
  ...handleId ? { handle_id: handleId } : {},
22279
23125
  importIds,
22280
- progress,
23126
+ ...handleDryRun === true ? { dry_run: true } : {},
23127
+ progress: notReady ? { ...progress, phase: "committing" } : progress,
23128
+ ...reconciled ? {
23129
+ result: {
23130
+ leads: reconciled.leads,
23131
+ not_imported: reconciled.not_imported,
23132
+ importIds,
23133
+ ...reconciled.still_settling > 0 ? { still_settling: reconciled.still_settling } : {}
23134
+ }
23135
+ } : {},
22281
23136
  ...failed ? {
22282
23137
  error: failed.pre_processing?.error ?? failed.processing?.error ?? "import failed"
22283
- } : {},
23138
+ } : commitError ? { error: `Import mappings were rejected: ${commitError}` } : {},
22284
23139
  region: client.region,
22285
23140
  _meta: client.lastMeta ?? {
22286
23141
  region: client.region,
@@ -24177,11 +25032,54 @@ async function readExtraRefillQuota(client) {
24177
25032
  return { count: null, resets_at: null };
24178
25033
  }
24179
25034
  }
25035
+ async function readAvailablePool(client, lensId) {
25036
+ try {
25037
+ const preview = await client.request("GET", `/lenses/${lensId}/extra_refill_preview`);
25038
+ return typeof preview?.available_count === "number" ? preview.available_count : null;
25039
+ } catch {
25040
+ return null;
25041
+ }
25042
+ }
25043
+ async function readLensLeadTotal(client, lensId) {
25044
+ try {
25045
+ const page = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=1&page=0`);
25046
+ return typeof page?.pagination?.total === "number" ? page.pagination.total : null;
25047
+ } catch {
25048
+ return null;
25049
+ }
25050
+ }
25051
+ function noCandidatesReason(held, shape) {
25052
+ const { geoSentence, ...extras } = shape;
25053
+ const futile = " Extending again is futile \u2014 a refill on a lens with an empty candidate pool reports queued, consumes no quota and delivers nothing.";
25054
+ if (held === 0) {
25055
+ return {
25056
+ code: "audience_too_narrow",
25057
+ retryable: false,
25058
+ message: "This lens holds no leads and has none left to add: its criteria intersect to nothing." + geoSentence + futile + " Tell the user which criteria are in play and offer to widen the audience (leadbay_adjust_audience).",
25059
+ ...extras
25060
+ };
25061
+ }
25062
+ if (held !== null) {
25063
+ return {
25064
+ code: "no_new_leads",
25065
+ retryable: false,
25066
+ message: `Every company matching this lens has already been delivered \u2014 all ${held} of them \u2014 so there is nothing left to add.` + futile + " Tell the user; offer to widen the audience (leadbay_adjust_audience) or work the leads already in the lens (leadbay_pull_followups).",
25067
+ ...extras
25068
+ };
25069
+ }
25070
+ return {
25071
+ code: "no_candidates",
25072
+ retryable: false,
25073
+ message: "This lens has no candidates left to add." + geoSentence + futile + " Tell the user and offer to widen the audience (leadbay_adjust_audience).",
25074
+ ...extras
25075
+ };
25076
+ }
24180
25077
  var extendLens;
24181
25078
  var init_extend_lens = __esm({
24182
25079
  "../core/dist/composite/extend-lens.js"() {
24183
25080
  "use strict";
24184
25081
  init_tool_descriptions_generated();
25082
+ init_empty_lens_reason();
24185
25083
  extendLens = {
24186
25084
  name: "leadbay_extend_lens",
24187
25085
  annotations: {
@@ -24218,7 +25116,7 @@ var init_extend_lens = __esm({
24218
25116
  properties: {
24219
25117
  status: {
24220
25118
  type: "string",
24221
- description: "queued | quota_exceeded | refresh_in_progress | no_valid_seeds"
25119
+ description: "queued | no_candidates | quota_exceeded | refresh_in_progress | no_valid_seeds"
24222
25120
  },
24223
25121
  lens: {
24224
25122
  type: "object",
@@ -24233,6 +25131,35 @@ var init_extend_lens = __esm({
24233
25131
  type: "string",
24234
25132
  description: "Human-readable summary. On error statuses, this is the line to surface to the user."
24235
25133
  },
25134
+ available_count: {
25135
+ type: ["number", "null"],
25136
+ description: "How many leads a refill on this lens could still draw, read from /extra_refill_preview before queueing. 0 means the refill was NOT queued (status=no_candidates). null means the pool could not be read and the refill was queued anyway."
25137
+ },
25138
+ reason: {
25139
+ type: "object",
25140
+ description: "Only present on status=no_candidates. Same shape and `code` vocabulary as leadbay_pull_leads' empty_reason, so one routing rule covers both tools.",
25141
+ properties: {
25142
+ code: {
25143
+ type: "string",
25144
+ description: "audience_too_narrow (lens holds nothing and its criteria intersect to nothing) | no_new_leads (everything matching has already been delivered) | no_candidates (pool is empty; which of the two could not be determined)"
25145
+ },
25146
+ message: { type: "string" },
25147
+ retryable: {
25148
+ type: "boolean",
25149
+ description: "Always false here. Re-calling leadbay_extend_lens cannot change the outcome \u2014 widen the audience instead."
25150
+ },
25151
+ criteria: {
25152
+ type: "object",
25153
+ description: "The criteria in play, so the agent can name them."
25154
+ },
25155
+ narrow_locations: {
25156
+ type: "array",
25157
+ description: "Include-locations that resolved to city-scale or smaller \u2014 the usual thing to relax first.",
25158
+ items: { type: "object" }
25159
+ }
25160
+ },
25161
+ required: ["code", "message", "retryable"]
25162
+ },
24236
25163
  quota: {
24237
25164
  type: "object",
24238
25165
  description: "Only present on status=quota_exceeded. Shows the org's daily LENS_EXTRA_REFILL state.",
@@ -24246,6 +25173,21 @@ var init_extend_lens = __esm({
24246
25173
  },
24247
25174
  execute: async (client, params) => {
24248
25175
  const lensId = params.lensId ?? await client.resolveDefaultLens();
25176
+ const availableCount = await readAvailablePool(client, lensId);
25177
+ if (availableCount === 0) {
25178
+ const [shape, held] = await Promise.all([
25179
+ readAudienceShape(client, lensId),
25180
+ readLensLeadTotal(client, lensId)
25181
+ ]);
25182
+ const reason = noCandidatesReason(held, shape);
25183
+ return {
25184
+ status: "no_candidates",
25185
+ lens: { id: lensId },
25186
+ available_count: 0,
25187
+ reason,
25188
+ message: reason.message
25189
+ };
25190
+ }
24249
25191
  const body = {
24250
25192
  seed_lead_ids: params.seed_lead_ids ?? []
24251
25193
  };
@@ -24257,6 +25199,7 @@ var init_extend_lens = __esm({
24257
25199
  return {
24258
25200
  status: "queued",
24259
25201
  lens: { id: lensId },
25202
+ available_count: availableCount,
24260
25203
  accepted_seeds: res.accepted_seeds,
24261
25204
  message: "Extra refill queued. Leads stream in asynchronously \u2014 call leadbay_pull_leads in ~30s to see them."
24262
25205
  };
@@ -25465,9 +26408,9 @@ var ARTIFACT_KIT_VERSION, ARTIFACT_RUNTIME, ARTIFACT_USAGE_GUIDE;
25465
26408
  var init_artifact_runtime_generated = __esm({
25466
26409
  "../core/dist/artifact-runtime.generated.js"() {
25467
26410
  "use strict";
25468
- ARTIFACT_KIT_VERSION = "0.3.1";
25469
- ARTIFACT_RUNTIME = '"use strict";(()=>{var _=Object.defineProperty;var k=(e,t,n)=>t in e?_(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n;var o=(e,t,n)=>k(e,typeof t!="symbol"?t+"":t,n);var T="0.3.1",d=class extends Error{constructor(n,i={}){super(n);o(this,"code");o(this,"raw");this.name="LbError",this.code=i.code,this.raw=i.raw}},v=null,f=3e4;function L(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(t,n)=>e.callMcpTool(t,n):null}function p(e){if(e&&typeof e=="object"&&"content"in e){let t=e.content;if(Array.isArray(t)&&t[0]&&typeof t[0].text=="string")return t[0].text}return null}function y(e){if(!e||typeof e!="object")return e;let t=e;if(t.isError)throw new d(p(e)??"tool call failed",{raw:e});if("structuredContent"in t&&t.structuredContent!=null)return t.structuredContent;let n=p(e);if(n!=null)try{return JSON.parse(n)}catch{return n}return e}function E(e){return e instanceof Error?e.message:String(e)}function m(e){let t=e instanceof d?e.code:void 0;return{message:E(e),unavailable:t==="unavailable",code:t}}function S(e={}){v=e.call??null,f=e.timeoutMs??3e4}async function w(e,t){if(!f||f<=0)return e;let n,i=new Promise((r,a)=>{n=setTimeout(()=>a(new d(`"${t}" timed out after ${f}ms`,{code:"timeout"})),f)});try{return await Promise.race([e,i])}finally{n&&clearTimeout(n)}}async function s(e,t={}){if(v)return y(await w(Promise.resolve(v(e,t)),e));let n=L();if(!n)throw new d("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});return y(await w(Promise.resolve(n(e,t)),e))}var c=class{constructor(){o(this,"subs",new Set)}subscribe(t){return this.subs.add(t),t(this),()=>this.subs.delete(t)}emit(){for(let t of this.subs)t(this)}};function A(e){return Array.isArray(e)?e.map(t=>t&&typeof t=="object"?t:{value:t,label:String(t)}):[]}var h=class extends c{constructor(n={}){super();o(this,"kind");o(this,"value");o(this,"options",[]);o(this,"loading",!1);o(this,"error",null);o(this,"ready",!1);o(this,"cfg");o(this,"depUnsubs",[]);o(this,"seq",0);this.cfg=n,this.kind=n.kind,this.value=n.value??"";for(let i of n.dependsOn??[]){let r=i.value;this.depUnsubs.push(i.subscribe(()=>{i.value!==r&&(r=i.value,this.cfg.load&&this.load())}))}n.load&&(n.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let n=++this.seq;this.loading=!0,this.error=null,this.emit();try{let i=await this.cfg.load();if(n!==this.seq)return;this.options=this.cfg.options?this.cfg.options(i):A(i),this.ready=!0;let r=this.value==null?"":String(this.value);this.options.length&&(r===""||!this.options.some(a=>String(a.value)===r))&&(this.value=this.options[0].value)}catch(i){if(n!==this.seq)return;this.options=[],this.error=m(i)}finally{n===this.seq&&(this.loading=!1,this.emit())}}setValue(n){this.value=n;let i=this.validate();this.error=i?{message:i,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let n of this.depUnsubs)n();this.depUnsubs=[]}},l=class extends c{constructor(n){super();o(this,"loading",!1);o(this,"error",null);o(this,"lastResult",null);o(this,"cfg");this.cfg=n}async run(){if(this.loading)return;for(let i of this.cfg.fields??[]){let r=i.validate();if(r!=null){this.error={message:r,unavailable:!1},this.emit();return}}if(this.cfg.confirm&&typeof globalThis.confirm=="function"&&!globalThis.confirm(this.cfg.confirm))return;this.loading=!0,this.error=null,this.emit();let n;try{let i=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};n=await s(this.cfg.tool,i)}catch(i){this.error=m(i),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}return this.lastResult=n,this.loading=!1,this.emit(),this.cfg.onSuccess?.(n),n}reset(){this.error=null,this.lastResult=null,this.emit()}},u=class extends c{constructor(n){super();o(this,"data",null);o(this,"loading",!1);o(this,"refreshing",!1);o(this,"error",null);o(this,"done",!1);o(this,"cfg");o(this,"timer",null);o(this,"seq",0);this.cfg=n,(n.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let n=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let r=await this.cfg.load();if(n!==this.seq)return;this.data=r,this.done=this.cfg.until?this.cfg.until(r):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(r){if(n!==this.seq)return;this.error=m(r)}finally{n===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},b=class extends c{constructor(n){super();o(this,"items",[]);o(this,"page",0);o(this,"pageSize");o(this,"total",0);o(this,"loading",!1);o(this,"error",null);o(this,"cfg");o(this,"seq",0);this.cfg=n,this.pageSize=n.pageSize??20,(n.autoLoad??!0)&&this.loadPage(0)}async loadPage(n){let i=++this.seq;this.loading=!0,this.error=null,this.emit();try{let r=await this.cfg.load({page:n,pageSize:this.pageSize});if(i!==this.seq)return;this.items=r.items??[],this.total=r.total??this.items.length,this.page=n}catch(r){if(i!==this.seq)return;this.error=m(r)}finally{i===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function x(e,t){let n=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":"ready";e.setAttribute("data-lb-state",n),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")}function C(e,t){let n=()=>t.setValue(e.value);e.addEventListener("change",n);let i=t.subscribe(()=>{x(e,t),e.disabled=t.loading,e.innerHTML="";for(let r of t.options){let a=document.createElement("option");a.value=String(r.value),a.textContent=r.label,e.appendChild(a)}e.value=t.value==null?"":String(t.value)});return()=>{e.removeEventListener("change",n),i()}}function R(e,t){let n=e.type==="checkbox",i=e.tagName==="SELECT"?"change":"input",r=()=>t.setValue(n?e.checked:e.value);e.addEventListener(i,r);let a=t.subscribe(()=>{if(n)e.checked=!!t.value;else{let g=t.value==null?"":String(t.value);e.value!==g&&(e.value=g)}e.setAttribute("data-lb-state",t.error?"error":"ready"),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(i,r),a()}}function I(e,t){let n=r=>{r.preventDefault(),t.run()};e.addEventListener("click",n);let i=t.subscribe(()=>{let r=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":t.lastResult!=null?"success":"idle";e.setAttribute("data-lb-state",r),"disabled"in e&&(e.disabled=t.loading),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener("click",n),i()}}var O=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"];function P(e){return new h({kind:"select",load:()=>s("leadbay_list_campaigns",{_triggered_by:e}),options:t=>(t?.campaigns??[]).map(i=>{let r=i?.campaign??i;return r?.id?{value:r.id,label:r.name??r.ai_generated_name??String(r.id)}:null}).filter(i=>i!=null)})}function M(e){return new l({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function F(e){return new l({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function q(e){return new l({tool:"leadbay_like_lead",args:{lead_id:e}})}function H(e){return new l({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function z(e,t){return new u({autoLoad:!1,load:()=>s("leadbay_account_history",{leadId:e,_triggered_by:t})})}function N(e,t){return new u({autoLoad:!1,load:()=>s("leadbay_research_lead_by_id",{leadId:e,_triggered_by:t})})}function U(e){let t=null;return new u({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:n=>!!n?.all_done,load:async()=>{if(!t){let n=await s("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask});if(t=n?.bulk_id??null,!t)return{...n,all_done:!0,no_job:!0}}return s("leadbay_bulk_enrich_status",{bulk_id:t,_triggered_by:e.ask})}})}function D(e){let t=e.source??"followups";return new b({pageSize:e.pageSize??20,load:async({page:n,pageSize:i})=>{let a=t==="campaign"?await s("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:n,count:i,_triggered_by:e.ask}):await s("leadbay_pull_followups",{page:n,count:i,...e.city?{city:e.city}:{},_triggered_by:e.ask}),g=a.leads??a.items??[];return{items:g,total:a.total_leads??a.pagination?.total??g.length}}})}function V(e){return new u({load:()=>s("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var j={VERSION:T,configure:S,call:s,field:e=>new h(e),action:e=>new l(e),resource:e=>new u(e),list:e=>new b(e),bindSelect:C,bindValue:R,bindAction:I,campaigns:P,outreach:M,note:F,like:q,dislike:H,leadHistory:z,leadProfile:N,enrichment:U,callList:D,teamActivity:V,EPILOGUE_STATUSES:O};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=j);})();';
25470
- ARTIFACT_USAGE_GUIDE = '# Leadbay Artifact Kit \u2014 headless domain components\n\nYou are building a single-file HTML **artifact** the user runs inside cowork. This\nkit gives you **headless view-models** that own a control\'s whole data lifecycle \u2014\nload/populate from a Leadbay call, hold value/state, poll, validate, and\nencapsulate the API call + business rules. **You own 100% of markup/layout/style.**\nThe library renders nothing. Inline the runtime once as a `<script>`; it exposes\none global `window.LeadbayArtifacts` (call it `lb`). Vanilla, no React, no build.\n\nPass every tool you use as the artifact\'s `mcp_tools` so the host permits it.\n\n## Two layers\n\n**Primitives** (generic):\n- `lb.field({ load, options, value, validate, dependsOn })` \u2014 a value + optionally\n API-populated options. `.value/.setValue/.options/.loading/.error/.valid/.subscribe`.\n- `lb.action({ tool, args, fields, confirm, onSuccess, onError })` \u2014 a write/submit.\n `.run()/.loading/.error/.lastResult/.subscribe`.\n- `lb.resource({ load, pollEvery?, until?, autoLoad? })` \u2014 one read that may change:\n load-on-click or poll-until-`until`. `.data/.loading/.refreshing/.error/.done/.load()/.refresh()/.stop()/.subscribe`.\n- `lb.list({ load, pageSize })` \u2014 paginated rows. `.items/.page/.total/.loading/.loadPage(n)/.next()/.prev()/.hasMore/.subscribe`.\n\n`.error` is `{ message, unavailable } | null`. `subscribe(cb)` fires immediately\nthen on every change \u2014 render your own DOM from it.\n\n**Domain components** (pre-wired \u2014 bake in the tool name, arg shape, and footguns):\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.campaigns(ask)` | field | a campaign `<select>`, options from `leadbay_list_campaigns` |\n| `lb.outreach({leadId, ask, status?, note?})` | action | log a call \u2192 `report_outreach` (verification + `_triggered_by` baked in) |\n| `lb.note({leadId, note})` | action | add a note \u2192 `add_note` |\n| `lb.like(leadId)` / `lb.dislike(leadId)` | action | taste signal |\n| `lb.leadHistory(leadId, ask)` | resource (lazy) | notes + activities + engagement \u2192 `account_history` |\n| `lb.leadProfile(leadId, ask)` | resource (lazy) | full lead profile \u2192 `research_lead_by_id` |\n| `lb.callList({source:\'followups\'\\|\'campaign\', campaignId?, city?, ask})` | list | a cold-call list (Monitor or a campaign) |\n| `lb.enrichment({leadIds, titles, ask, pollEvery?})` | resource (polling) | launch + watch contact enrichment |\n| `lb.teamActivity({weeks, ask})` | resource | manager leaderboard + activity trend \u2192 `leadbay_team_activity` |\n\n`lb.EPILOGUE_STATUSES` = the 4 disposition values\n(`STILL_CHASING`, `COULD_NOT_REACH_STILL_TRYING`, `INTEREST_VALIDATED_OR_MEETING_PLANED`, `NOT_INTERESTED_LOST`).\n\n**Binding sugar** (optional; binds a view-model to YOUR native element, no style):\n`lb.bindSelect(selectEl, field)` (populates options + value), `lb.bindValue(inputEl, field)`,\n`lb.bindAction(buttonEl, action)`. They set `data-lb-state`\n(`ready|loading|error|success|unavailable`) + `data-lb-error` on your element as\nstyling hooks. For lists/resources, use `.subscribe()` and render yourself.\n\n`ask` is the user\'s request this artifact serves \u2014 it becomes `_triggered_by`.\n\n## Recipe: cold-call sheet (one row per lead)\n\n```js\nconst lb = window.LeadbayArtifacts; lb.configure();\nconst ASK = "<the user\'s request>";\n\nconst list = lb.callList({ source: "campaign", campaignId: CID, ask: ASK });\nlist.subscribe((l) => renderRows(l.items, l.loading)); // your render\n\n// per lead row (call when you build a row):\nfunction wireRow(lead, els) {\n const status = lb.field({ value: "STILL_CHASING" }); // static-enum <select>\n const note = lb.field({ validate: (v) => (v && v.trim() ? null : "Add a note") });\n lb.bindValue(els.status, status);\n lb.bindValue(els.note, note);\n lb.bindAction(els.log, lb.outreach({ leadId: lead.id, ask: ASK, status, note }));\n lb.bindAction(els.like, lb.like(lead.id));\n\n const history = lb.leadHistory(lead.id, ASK); // lazy\n history.subscribe((h) => renderHistory(els.history, h));\n els.expand.onclick = () => history.load(); // load on click\n}\n```\n\n## Recipe: manager dashboard\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.loading) showSpinner();\n if (t.data) {\n renderLeaderboard(t.data.reps); // sorted by total_activities; cols: name, notes, meetings_or_interest, lost\u2026\n renderTrendChart(t.data.trend); // [{date,count}] \u2192 Chart.js (allowed from CDN)\n }\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\n## Recipe: live enrichment\n\n```js\nconst job = lb.enrichment({ leadIds: [LEAD], titles: ["CEO", "VP Sales"], ask: ASK });\njob.subscribe((j) => {\n const p = j.data && j.data.overall_progress; // {done,total,done_ratio}\n renderBar(p);\n if (j.done) renderContacts(j.data.leads); // enriched contacts\n});\nrefreshBtn.onclick = () => job.refresh();\n```\n\n## Write-call rules\n\nThe domain factories handle these for you. If you hand-roll an action:\n`leadbay_report_outreach` args MUST include `verification:{source:"user_confirmed", ref}`\nAND `_triggered_by`; `leadbay_add_leads_to_campaign` needs `_triggered_by`;\n`add_note`/`like_lead`/`dislike_lead` take only their own args. `epilogue_status` is\none of `lb.EPILOGUE_STATUSES`. Snoozing (pushback) and org WON/LOST status are\nadvanced-gated \u2014 not callable from a default artifact; use the epilogue values.\n\n## Degradation + live updates\n\nIf the host bridge is absent, a view-model\'s `.error` is set with `.error.unavailable\n=== true` (bind helpers set `data-lb-state="unavailable"`) \u2014 nothing throws. Every\ncall also has a **30s timeout** (configurable via `lb.configure({ timeoutMs })`): a\nhost call that never settles becomes `.error` with `code:"timeout"`, so a control is\nnever stuck loading forever \u2014 always render the `.error` branch so the user can retry.\nAuto-poll (`pollEvery`) depends on the cowork host serving FRESH reads; `.refresh()`\nis the guaranteed manual path \u2014 always wire a Refresh control for polling resources.';
26411
+ ARTIFACT_KIT_VERSION = "0.5.0";
26412
+ ARTIFACT_RUNTIME = '"use strict";(()=>{var L=Object.defineProperty;var A=(e,r,t)=>r in e?L(e,r,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[r]=t;var a=(e,r,t)=>A(e,typeof r!="symbol"?r+"":r,t);var S=`\n:root{\n--color-black:#191919;--color-white:#fff;\n--color-gray-1:#f9f9f9;--color-gray-2:#f0f0f0;--color-gray-3:#e0e0e0;--color-gray-4:#cecece;\n--color-gray-5:#c4c4c4;--color-gray-6:#8d8d8d;--color-gray-7:#787878;--color-gray-8:#646464;\n--color-gray-9:#202020;\n--color-linkedin:#0a66c2;\n--color-blue-background:oklch(0.947 0.029 251);--color-blue-foreground:oklch(0.564 0.181 251);\n--color-green-background:oklch(0.947 0.029 141);--color-green-foreground:oklch(0.564 0.181 141);\n--color-red-background:oklch(0.947 0.029 26);--color-red-foreground:oklch(0.564 0.191 26);\n--color-gold-background:oklch(0.972 0.049 91);--color-gold-foreground:oklch(0.667 0.177 91);\n--color-cherry-background:oklch(0.947 0.029 15);--color-cherry-foreground:oklch(0.44 0.146 15);\n--color-red-like:var(--color-cherry-foreground);\n--lb-font:"Nikkei Maru",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;\n--lb-radius:1rem;--lb-radius-sm:0.625rem;--lb-gap:0.75rem;\n--lb-surface:var(--color-gray-1);--lb-border:var(--color-gray-3);\n--lb-fg:var(--color-black);--lb-muted:var(--color-gray-8);--lb-field:var(--color-white);\n}\n:root[data-theme=dark],:root[data-lb-theme=dark]{\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}\n@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-lb-theme=light]){\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}}\n.lb-card{display:grid;gap:var(--lb-gap);padding:0.875rem;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius);corner-shape:squircle;color:var(--lb-fg);\nfont-family:var(--lb-font);\nbox-shadow:0 1rem 2.5rem color-mix(in srgb,var(--color-gray-9) 6%,transparent),\n0 0.125rem 0.5rem color-mix(in srgb,var(--color-gray-9) 4%,transparent)}\n.lb-card-head{display:flex;justify-content:space-between;align-items:baseline;gap:var(--lb-gap)}\n.lb-title{font-size:0.875rem;font-weight:600;line-height:1.25rem;color:var(--lb-fg)}\n.lb-sub{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-row{display:flex;align-items:center;gap:var(--lb-gap);flex-wrap:wrap}\n.lb-stack{display:grid;gap:var(--lb-gap)}\n.lb-select,.lb-input{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;color:var(--lb-fg);\nbackground-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.55rem;min-height:2.125rem}\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--lb-fg);background-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.85rem;min-height:2.125rem;\ncursor:pointer;transition:background-color .15s,border-color .15s,color .15s}\n.lb-btn:hover:not([disabled]){border-color:var(--color-gray-6)}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible{\noutline:2px solid var(--color-blue-foreground);outline-offset:1px}\n.lb-btn[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-btn[data-lb-state=success]{background-color:var(--color-green-background);\nborder-color:var(--color-green-foreground);color:var(--color-green-foreground)}\n.lb-btn[data-lb-state=error],.lb-select[data-lb-state=error]{\nbackground-color:var(--color-red-background);border-color:var(--color-red-foreground);\ncolor:var(--color-red-foreground)}\n.lb-btn[data-lb-state=unavailable],.lb-btn[disabled]{opacity:.5;cursor:not-allowed}\n.lb-msg{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-msg[data-tone=error]{color:var(--color-red-foreground)}\n.lb-msg[data-tone=ok]{color:var(--color-green-foreground)}\n.lb-chip{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;\nfont-size:0.75rem;font-weight:600;line-height:1rem;padding:0.125rem 0.5rem;\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;\nbackground-color:var(--color-gray-2);color:var(--lb-muted)}\n.lb-chip[data-status=WANTED]{background-color:var(--color-blue-background);color:var(--color-blue-foreground)}\n.lb-chip[data-status=WON]{background-color:var(--color-green-background);color:var(--color-green-foreground)}\n.lb-chip[data-status=LOST]{background-color:var(--color-red-background);color:var(--color-red-foreground)}\n.lb-chip[data-status=UNWANTED]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chip[data-taste=liked]{background-color:var(--color-cherry-background);color:var(--color-red-like)}\n.lb-chip[data-taste=disliked]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chips{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}\n.lb-chip[hidden]{display:none}\n.lb-table{width:100%;border-collapse:collapse;font-family:var(--lb-font);color:var(--lb-fg)}\n.lb-table th,.lb-table td{text-align:left;padding:0.5rem 0.4rem;\nborder-bottom:1px solid var(--lb-border);vertical-align:middle;font-size:0.8125rem}\n.lb-table th{font-size:0.75rem;font-weight:600;color:var(--lb-muted);\ntext-transform:uppercase;letter-spacing:.04em}\n.lb-link{color:var(--color-blue-foreground);text-decoration:none}\n.lb-link:hover{text-decoration:underline}\n/* Quiet text link out of the artifact. Button-height so it shares the row\'s\n baseline; understated so it never competes with the actions beside it. The\n arrow is a bare diagonal stroke \\u2014 an escape-hatch marker, not an icon that\n asks to be read. */\n.lb-link-out{display:inline-flex;align-items:center;gap:.3rem;\nfont-size:0.75rem;line-height:1rem;min-height:2.125rem;\ncolor:var(--lb-fg);text-decoration:none;opacity:.65;transition:opacity .15s}\n.lb-link-out:hover{opacity:1;text-decoration:underline}\n.lb-link-out:focus-visible{outline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n.lb-link-out svg{width:.85em;height:.85em;flex-shrink:0}\n/* Pushes whatever follows it to the right edge of an .lb-row, so a trailing\n link sits on the SAME baseline as the row\'s buttons instead of on its own\n line below them. */\n.lb-spacer{flex:1 1 auto}\n.lb-spinner{display:inline-block;width:.7em;height:.7em;border:2px solid var(--lb-border);\nborder-top-color:var(--color-blue-foreground);border-radius:50%;animation:lb-spin .8s linear infinite}\n@keyframes lb-spin{to{transform:rotate(1turn)}}\n@media(prefers-reduced-motion:reduce){.lb-spinner{animation:none}\n.lb-btn{transition-property:none}}\n`,p="lb-styles";var x="0.5.0",c=class extends Error{constructor(t,o={}){super(t);a(this,"code");a(this,"raw");this.name="LbError",this.code=o.code,this.raw=o.raw}},y=null,f=3e4;function C(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(r,t)=>e.callMcpTool(r,t):null}function _(e){if(e&&typeof e=="object"&&"content"in e){let r=e.content;if(Array.isArray(r)&&r[0]&&typeof r[0].text=="string")return r[0].text}return null}function E(e){if(!e||typeof e!="object")return e;let r=e;if(r.isError)throw new c(_(e)??"tool call failed",{raw:e});if("structuredContent"in r&&r.structuredContent!=null)return r.structuredContent;let t=_(e);if(t!=null)try{return JSON.parse(t)}catch{return t}return e}function I(e){return e instanceof Error?e.message:String(e)}function v(e){let r=e instanceof c?e.code:void 0;return{message:I(e),unavailable:r==="unavailable",code:r}}function O(e={}){y=e.call??null,f=e.timeoutMs??3e4}function R(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(p);if(e)return e;let r=document.createElement("style");return r.id=p,r.textContent=S,document.head.appendChild(r),r}async function T(e,r){if(!f||f<=0)return e;let t,o=new Promise((n,i)=>{t=setTimeout(()=>i(new c(`"${r}" timed out after ${f}ms`,{code:"timeout"})),f)});try{return await Promise.race([e,o])}finally{t&&clearTimeout(t)}}async function l(e,r={}){if(y)return E(await T(Promise.resolve(y(e,r)),e));let t=C();if(!t)throw new c("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});return E(await T(Promise.resolve(t(e,r)),e))}var g=class{constructor(){a(this,"subs",new Set)}subscribe(r){return this.subs.add(r),r(this),()=>this.subs.delete(r)}emit(){for(let r of this.subs)r(this)}};function N(e){return Array.isArray(e)?e.map(r=>r&&typeof r=="object"?r:{value:r,label:String(r)}):[]}var b=class extends g{constructor(t={}){super();a(this,"kind");a(this,"value");a(this,"options",[]);a(this,"loading",!1);a(this,"error",null);a(this,"ready",!1);a(this,"cfg");a(this,"depUnsubs",[]);a(this,"seq",0);this.cfg=t,this.kind=t.kind,this.value=t.value??"";for(let o of t.dependsOn??[]){let n=o.value;this.depUnsubs.push(o.subscribe(()=>{o.value!==n&&(n=o.value,this.cfg.load&&this.load())}))}t.load&&(t.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let t=++this.seq;this.loading=!0,this.error=null,this.emit();try{let o=await this.cfg.load();if(t!==this.seq)return;this.options=this.cfg.options?this.cfg.options(o):N(o),this.ready=!0;let n=this.value==null?"":String(this.value);this.options.length&&(n===""||!this.options.some(i=>String(i.value)===n))&&(this.value=this.options[0].value)}catch(o){if(t!==this.seq)return;this.options=[],this.error=v(o)}finally{t===this.seq&&(this.loading=!1,this.emit())}}setValue(t){this.value=t;let o=this.validate();this.error=o?{message:o,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let t of this.depUnsubs)t();this.depUnsubs=[]}};function P(e){if(!e||typeof e!="object")return null;let r=e;if(r.error!==!0)return null;let t=typeof r.message=="string"&&r.message?r.message:"tool call failed",o=typeof r.hint=="string"&&r.hint?` \\u2014 ${r.hint}`:"";return`${t}${o}`}var s=class extends g{constructor(t){super();a(this,"loading",!1);a(this,"error",null);a(this,"lastResult",null);a(this,"cfg");this.cfg=t}async run(){if(this.loading)return;for(let n of this.cfg.fields??[]){let i=n.validate();if(i!=null){this.error={message:i,unavailable:!1},this.emit();return}}if(this.cfg.confirm&&typeof globalThis.confirm=="function"&&!globalThis.confirm(this.cfg.confirm))return;this.loading=!0,this.error=null,this.emit();let t;try{let n=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};t=await l(this.cfg.tool,n)}catch(n){this.error=v(n),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let o=P(t)??this.cfg.checkResult?.(t)??null;if(o!=null){this.error={message:o,unavailable:!1},this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}return this.lastResult=t,this.loading=!1,this.emit(),this.cfg.onSuccess?.(t),t}reset(){this.error=null,this.lastResult=null,this.emit()}},d=class extends g{constructor(t){super();a(this,"data",null);a(this,"loading",!1);a(this,"refreshing",!1);a(this,"error",null);a(this,"done",!1);a(this,"cfg");a(this,"timer",null);a(this,"seq",0);this.cfg=t,(t.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let t=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let n=await this.cfg.load();if(t!==this.seq)return;this.data=n,this.done=this.cfg.until?this.cfg.until(n):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(n){if(t!==this.seq)return;this.error=v(n)}finally{t===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},h=class extends g{constructor(t){super();a(this,"items",[]);a(this,"page",0);a(this,"pageSize");a(this,"total",0);a(this,"loading",!1);a(this,"error",null);a(this,"cfg");a(this,"seq",0);this.cfg=t,this.pageSize=t.pageSize??20,(t.autoLoad??!0)&&this.loadPage(0)}async loadPage(t){let o=++this.seq;this.loading=!0,this.error=null,this.emit();try{let n=await this.cfg.load({page:t,pageSize:this.pageSize});if(o!==this.seq)return;this.items=n.items??[],this.total=n.total??this.items.length,this.page=t}catch(n){if(o!==this.seq)return;this.error=v(n)}finally{o===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function M(e,r){let t=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":"ready";e.setAttribute("data-lb-state",t),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")}function D(e,r){let t=()=>r.setValue(e.value);e.addEventListener("change",t);let o=r.subscribe(()=>{M(e,r),e.disabled=r.loading,e.innerHTML="";for(let n of r.options){let i=document.createElement("option");i.value=String(n.value),i.textContent=n.label,e.appendChild(i)}e.value=r.value==null?"":String(r.value)});return()=>{e.removeEventListener("change",t),o()}}function z(e,r){let t=e.type==="checkbox",o=e.tagName==="SELECT"?"change":"input",n=()=>r.setValue(t?e.checked:e.value);e.addEventListener(o,n);let i=r.subscribe(()=>{if(t)e.checked=!!r.value;else{let u=r.value==null?"":String(r.value);e.value!==u&&(e.value=u)}e.setAttribute("data-lb-state",r.error?"error":"ready"),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(o,n),i()}}function F(e,r){let t=n=>{n.preventDefault(),r.run()};e.addEventListener("click",t);let o=r.subscribe(()=>{let n=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":r.lastResult!=null?"success":"idle";e.setAttribute("data-lb-state",n),"disabled"in e&&(e.disabled=r.loading),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener("click",t),o()}}var q=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],k=[{value:"",label:"Default ranking"},{value:"SCORE:DESC",label:"Score \\u2193"},{value:"SCORE:ASC",label:"Score \\u2191"},{value:"NAME:ASC",label:"Name A\\u2192Z"},{value:"NAME:DESC",label:"Name Z\\u2192A"},{value:"SIZE:DESC",label:"Size \\u2193"},{value:"SIZE:ASC",label:"Size \\u2191"},{value:"SECTOR:ASC",label:"Sector A\\u2192Z"},{value:"STATUS:ASC",label:"Status A\\u2192Z"},{value:"CONTACT_COUNT:DESC",label:"Contacts \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:DESC",label:"Last action \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:ASC",label:"Last action \\u2191"},{value:"EPILOGUE_STATUS_SET_AT:DESC",label:"Outcome set \\u2193"},{value:"LIKED:DESC",label:"Liked first"},{value:"DISLIKED:DESC",label:"Disliked first"}];function U(e){let r=String(e??"").trim().toUpperCase(),t=k.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",load:async()=>k.slice()})}var m=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],H={value:"",label:"\\u2014 Not set \\u2014"};function W(e){let r=String(e??"").trim().toUpperCase(),t=m.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",validate:o=>String(o??"")===""?"Pick a status":null,load:async()=>t?m.slice():[H,...m]})}function $(e){let r=()=>{let t=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(t)?t:e.leadId?[e.leadId]:[]};return new s({tool:"leadbay_set_lead_status",fields:e.date?[e.status,e.date]:[e.status],confirm:e.confirm,args:()=>({lead_ids:r(),status:e.status.value,...e.date&&e.date.value?{status_date:e.date.value}:{},...e.ask?{_triggered_by:e.ask}:{}}),checkResult:t=>{let o=t?.failed;if(!Array.isArray(o)||o.length===0)return null;let n=r().length,i=o[0]?.message??"write rejected";return o.length===n?`Status not applied: ${i}`:`${o.length} of ${n} leads failed: ${i}`}})}function j(e){return new b({kind:"select",load:()=>l("leadbay_list_campaigns",{_triggered_by:e}),options:r=>(r?.campaigns??[]).map(o=>{let n=o?.campaign??o;return n?.id?{value:n.id,label:n.name??n.ai_generated_name??String(n.id)}:null}).filter(o=>o!=null)})}function V(e){return new s({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function G(e){return new s({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function Z(e){return new s({tool:"leadbay_like_lead",args:{lead_id:e}})}function Y(e){return new s({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function B(e,r){return new d({autoLoad:!1,load:()=>l("leadbay_account_history",{leadId:e,_triggered_by:r})})}function K(e,r){return new d({autoLoad:!1,load:()=>l("leadbay_research_lead_by_id",{leadId:e,_triggered_by:r})})}function J(e){let r=null;return new d({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:t=>!!t?.all_done,load:async()=>{if(!r){let t=await l("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask});if(r=t?.bulk_id??null,!r)return{...t,all_done:!0,no_job:!0}}return l("leadbay_bulk_enrich_status",{bulk_id:r,_triggered_by:e.ask})}})}function Q(e){let r=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:t,pageSize:o})=>{let n=await l("leadbay_pull_leads",{page:t,count:o,...e.lensId?{lensId:e.lensId}:{},...r()?{order:r()}:{},_triggered_by:e.ask}),i=n.leads??[];return{items:i,total:n.pagination?.total??i.length}}})}function X(e){let r=e.source??"followups",t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:o,pageSize:n})=>{let u=r==="campaign"?await l("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:o,count:n,_triggered_by:e.ask}):await l("leadbay_pull_followups",{page:o,count:n,...e.city?{city:e.city}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),w=u.leads??u.items??[];return{items:w,total:u.total_leads??u.pagination?.total??w.length}}})}function ee(e){return new d({load:()=>l("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var re={VERSION:x,configure:O,styles:R,call:l,field:e=>new b(e),action:e=>new s(e),resource:e=>new d(e),list:e=>new h(e),bindSelect:D,bindValue:z,bindAction:F,campaigns:j,outreach:V,note:G,like:Z,dislike:Y,leadStatus:W,setStatus:$,sortOrder:U,leadHistory:B,leadProfile:K,enrichment:J,callList:X,leadList:Q,teamActivity:ee,EPILOGUE_STATUSES:q,LEAD_STATUSES:m,SORT_ORDERS:k};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=re);})();';
26413
+ ARTIFACT_USAGE_GUIDE = '# Leadbay Artifact Kit \u2014 headless domain components\n\nYou are building a single-file HTML **artifact** the user runs inside cowork. This\nkit gives you **headless view-models** that own a control\'s whole data lifecycle \u2014\nload/populate from a Leadbay call, hold value/state, poll, validate, and\nencapsulate the API call + business rules. **You own 100% of markup/layout/style.**\nThe library renders nothing. Inline the runtime once as a `<script>`; it exposes\none global `window.LeadbayArtifacts` (call it `lb`). Vanilla, no React, no build.\n\nPass every tool you use as the artifact\'s `mcp_tools` so the host permits it.\n\n## Two layers\n\n**Primitives** (generic):\n- `lb.field({ load, options, value, validate, dependsOn })` \u2014 a value + optionally\n API-populated options. `.value/.setValue/.options/.loading/.error/.valid/.subscribe`.\n- `lb.action({ tool, args, fields, confirm, onSuccess, onError })` \u2014 a write/submit.\n `.run()/.loading/.error/.lastResult/.subscribe`.\n- `lb.resource({ load, pollEvery?, until?, autoLoad? })` \u2014 one read that may change:\n load-on-click or poll-until-`until`. `.data/.loading/.refreshing/.error/.done/.load()/.refresh()/.stop()/.subscribe`.\n- `lb.list({ load, pageSize })` \u2014 paginated rows. `.items/.page/.total/.loading/.loadPage(n)/.next()/.prev()/.hasMore/.subscribe`.\n\n`.error` is `{ message, unavailable } | null`. `subscribe(cb)` fires immediately\nthen on every change \u2014 render your own DOM from it.\n\n**Domain components** (pre-wired \u2014 bake in the tool name, arg shape, and footguns):\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.campaigns(ask)` | field | a campaign `<select>`, options from `leadbay_list_campaigns` |\n| `lb.outreach({leadId, ask, status?, note?})` | action | log a call \u2192 `report_outreach` (verification + `_triggered_by` baked in) |\n| `lb.note({leadId, note})` | action | add a note \u2192 `add_note` |\n| `lb.like(leadId)` / `lb.dislike(leadId)` | action | taste signal |\n| `lb.leadStatus(current?)` | field | a status `<select>` (Wanted/Won/Lost/Unwanted) |\n| `lb.setStatus({leadId or leadIds, status, date?, ask})` | action | write the org CRM status \u2192 `set_lead_status` |\n| `lb.leadHistory(leadId, ask)` | resource (lazy) | notes + activities + engagement \u2192 `account_history` |\n| `lb.leadProfile(leadId, ask)` | resource (lazy) | full lead profile \u2192 `research_lead_by_id` |\n| `lb.sortOrder(current?)` | field | a sort `<select>` mirroring the app\'s TableSort |\n| `lb.leadList({lensId?, order?, ask})` | list | a sortable Discover batch \u2192 `pull_leads` |\n| `lb.callList({source:\'followups\'\\|\'campaign\', campaignId?, city?, ask})` | list | a cold-call list (Monitor or a campaign) |\n| `lb.enrichment({leadIds, titles, ask, pollEvery?})` | resource (polling) | launch + watch contact enrichment |\n| `lb.teamActivity({weeks, ask})` | resource | manager leaderboard + activity trend \u2192 `leadbay_team_activity` |\n\n`lb.EPILOGUE_STATUSES` = the 4 disposition values\n(`STILL_CHASING`, `COULD_NOT_REACH_STILL_TRYING`, `INTEREST_VALIDATED_OR_MEETING_PLANED`, `NOT_INTERESTED_LOST`).\n`lb.LEAD_STATUSES` = the 4 org CRM statuses as `{value,label}` (`WANTED`, `WON`, `LOST`, `UNWANTED`).\n`lb.SORT_ORDERS` = the sort options as `{value,label}`; values are the backend `FIELD:ASC|DESC` enum.\n\n**Sorting is a SERVER concern.** `lb.leadList` and `lb.callList` take an `order`\n(a `lb.sortOrder()` field or a literal) and send it upstream; the backend sorts\nthe whole lens / Monitor and returns the requested page of that. Never re-sort\nrows in the browser \u2014 you would be reordering one page of a larger set, showing\nleads that do not belong at that position. The empty value means "no order\nparam", i.e. the tab\'s own ranking, which is the right default. Changing the\nsort should reset to page 0. Campaign call sheets cannot sort:\n`leadbay_campaign_call_sheet` has no `order` param, and `lb.callList` drops it\nfor that source rather than sending something the tool would reject.\n\n**Two different systems.** Epilogue = how one outreach attempt went (drives\nfollow-up ranking). Lead status = the commercial outcome, org-wide \u2014 the same\nfield the website\'s status selector writes. A won deal is a LEAD STATUS;\n"she didn\'t pick up" is an EPILOGUE. Setting one never sets the other, so when\nthe user reports both in one breath, fire both actions.\n\n**Binding sugar** (optional; binds a view-model to YOUR native element, no style):\n`lb.bindSelect(selectEl, field)` (populates options + value), `lb.bindValue(inputEl, field)`,\n`lb.bindAction(buttonEl, action)`. They set `data-lb-state`\n(`ready|loading|error|success|unavailable`) + `data-lb-error` on your element as\nstyling hooks. For lists/resources, use `.subscribe()` and render yourself.\n\n`ask` is the user\'s request this artifact serves \u2014 it becomes `_triggered_by`.\n\n## The skin (optional) \u2014 `lb.styles()`\n\nCall it once and you get a small `lb-*` stylesheet, so every artifact you build\nshares one visual language instead of re-inventing padding and colours. It is\n**opt-in**: skip it and you get exactly the unstyled HTML you wrote. It injects\nno markup and never touches your `class` attributes.\n\n```js\nlb.styles(); // idempotent \u2014 safe to call per row\n```\n\n| Class | For |\n|---|---|\n| `lb-card` / `lb-card-head` / `lb-title` / `lb-sub` | a lead card + its header |\n| `lb-row` / `lb-stack` / `lb-spacer` | control row / vertical spacing / flex filler that right-aligns what follows |\n| `lb-link-out` | quiet external link (icon inherits currentColor) \u2014 "Open in Leadbay" |\n| `lb-select` / `lb-input` / `lb-btn` | form controls (state-aware, see below) |\n| `lb-msg` (`data-tone="error\\|ok"`) | inline feedback |\n| `lb-chip` (`data-status="WON\\|LOST"`) | a status pill |\n| `lb-table` | leads table |\n| `lb-spinner` | inline busy indicator |\n\nControls react to the `data-lb-state` the bind helpers already set \u2014 a bound\n`lb-btn` dims while loading, goes green on success, red on error, all with no\nextra CSS from you.\n\nThe palette is the **product design system**, ported from\n`frontend/packages/style/color.css` \u2014 same `--color-gray-1\u20269` ramp, same\nsemantic `--color-{green,red,blue,gold}-{background,foreground}` pairs, same\n`1rem` / `0.625rem` radii and `corner-shape: squircle` as the app\'s components.\nAn artifact therefore looks like Leadbay, not like a generic page.\n\nUse the tokens rather than hardcoded colours \u2014 the same rule the style package\nenforces. Re-theme by overriding them; don\'t fight specificity:\n\n```css\n:root { --lb-surface: var(--color-gray-2); --lb-radius: 0.5rem; }\n```\n\nDark mode works two ways: `data-theme="dark"` on `<html>` (the frontend\'s own\nhook) **and** `prefers-color-scheme`, because an artifact renders inside a host\nwhose theme attribute it cannot set. Never hardcode a light background over the\nskin.\n\nThe product face is `Nikkei Maru`; the stack names it first and falls back to\nthe system UI font. Do **not** add an `@font-face` \u2014 artifacts are inline-only\nand a remote font URL will silently fail.\n\n## What every lead card MUST carry\n\nA card is the artifact form of the `pull_leads` table, and it inherits that\ntable\'s rules. A card with a name and a button is not enough: the rep cannot\ntell *why* this lead is on screen. Four lines, in this order.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title"></span> <!-- 1. company -->\n <span class="lb-chips"> <!-- 2. state -->\n <span class="lb-chip" data-taste hidden></span>\n <span class="lb-chip" data-status hidden></span>\n </span>\n </div>\n <div class="lb-sub"></div> <!-- 3. firmographics -->\n <div class="lb-sub" data-why></div> <!-- 4. why it fits -->\n <div class="lb-row"><!-- actions --></div>\n</div>\n```\n\n1. **Company** \u2014 `name`, linked to `website` (prefix `https://` on a bare host).\n Never render the numeric `score`; use the `\u25B0\u2756\u25B1` bar if you want the signal.\n\n Also give every card an **Open in Leadbay** link to the lead\'s panel in the\n product. Put it at the **right-hand end of the card\'s last action row** \u2014\n same row as the buttons, pushed right by an `lb-spacer`, not on a line of\n its own. Style it `lb-link-out`: quiet text plus a plain arrow-up-right,\n never a filled button. It is an escape hatch, not a call to action.\n\n ```html\n <div class="lb-row">\n <button class="lb-btn">Like</button>\n <button class="lb-btn">Set status</button>\n <span class="lb-spacer"></span> <!-- pushes the link right -->\n <a class="lb-link-out" data-k="open" target="_blank" rel="noopener">\n Open in Leadbay\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/>\n </svg>\n </a>\n </div>\n ```\n\n Keep the arrow a bare diagonal stroke \u2014 the text already says where the link\n goes, so the glyph only has to mark "leaves this page". Mark the `<svg>`\n `aria-hidden="true"`: it is decorative, and the link text is the accessible\n name.\n **Pick the view the lead actually lives in** \u2014 the URL is\n `/app/<view>?lead=<uuid>`, and the three views are `discover`, `monitor`,\n `campaign`. Landing a Monitor lead on Discover drops the rep into a list\n that does not contain it:\n\n ```js\n function leadUrl(lead, campaignId) {\n const id = encodeURIComponent(lead.id);\n // A campaign card carries TWO params \u2014 the campaign selects the list, the\n // lead opens the panel inside it. Campaign wins even when in_monitor is\n // also true, because that is the list the rep is looking at.\n if (campaignId) {\n return `https://leadbay.app/app/campaign?campaign=${encodeURIComponent(campaignId)}&lead=${id}`;\n }\n const view = lead.in_monitor ? "monitor" : "discover";\n return `https://leadbay.app/app/${view}?lead=${id}`;\n }\n openEl.href = leadUrl(lead, campaignId);\n ```\n\n `in_monitor` / `in_discover` are booleans on the `pull_followups` payload \u2014\n every follow-up carries `in_monitor: true`, so a call sheet must link to\n `monitor`. `pull_leads` omits both flags entirely; its leads are the Discover\n batch by definition, so `discover` is the default. A campaign card\n (`lb.callList({source:"campaign", campaignId})`) needs `?campaign=<id>&lead=<id>`\n \u2014 the param names are `CAMPAIGN_QUERY_PARAM` and `LEAD_QUERY_PARAM`, and the\n app\'s own `useLeadPanel` preserves whatever params are already set, so the\n two coexist by design. Omitting `campaign=` opens an empty campaign view.\n\n Inline the glyph as SVG rather than an emoji or `\u2197` \u2014 it inherits\n `currentColor` and scales with the text, so it stays legible in both themes.\n `?lead=<uuid>` is the real deep-link (`LEAD_QUERY_PARAM` in the web app, read\n on load; the panel is an overlay, so the view choice only decides what sits\n behind it). This is the ONE place a card may use `lead.id`: as a link target,\n never as visible text.\n2. **State chips** \u2014 taste (`data-taste`) and CRM status (`data-status`) are\n INDEPENDENT axes; render both, hide the empty one. Never collapse to one chip.\n3. **Firmographics** \u2014 sector of activity first, then city, then size, then the\n contact. `sector_id` is a RAW ID (`"5136"`), not a label: resolve it via\n `leadbay_list_sectors` (1346 rows \u2014 fetch once, cache, never inline the lot)\n or omit it. Never print the raw id.\n\n **Always show whether the lead is reachable \u2014 and never merge the person\n with the company\'s switchboard.** These are two separate lines:\n\n ```js\n // WHO \u2014 recommended_contact. Name, and job_title ONLY when present; on list\n // payloads it is usually null, and inventing one is worse than omitting it.\n const rc = lead.recommended_contact;\n const who = rc ? [rc.first_name, rc.last_name].filter(Boolean).join(" ") : null;\n const whoLine = who ? who + (rc.job_title ? " \xB7 " + rc.job_title : "") : "No named contact";\n\n // HOW \u2014 company-level channels. `phone_numbers` and `email` belong to the\n // COMPANY, not to `recommended_contact`. Rendering "Jean \xB7 \u260E 0123\u2026" claims a\n // direct line that does not exist; it is the switchboard.\n const phone = (lead.phone_numbers || [])[0] || null;\n // The API returns the STRING "null" for a missing email \u2014 guard for it or\n // you will print the word "null" as an address.\n const email = lead.email && lead.email !== "null" ? lead.email : null;\n const howLine = [phone && "\u260E " + phone, email && "\u2709 " + email].filter(Boolean)\n .join(" \xB7 ") || "No direct channel \u2014 enrich to reveal";\n ```\n\n ```html\n <div class="lb-sub">Sector \xB7 City \xB7 Size</div>\n <div class="lb-sub">\u{1F464} Jean-Fran\xE7ois Froemer \xB7 G\xE9rant</div> <!-- WHO -->\n <div class="lb-sub">\u{1F3E2} \u260E 01 23 45 67 89</div> <!-- HOW: company -->\n ```\n\n Label the channel line as the **company\'s**, so a rep reading fast cannot\n mistake it for a direct line. A per-contact email or phone exists only after\n enrichment \u2014 `research_lead_by_id` exposes it as `contacts.reachable[]`, and\n `_meta.has_reachable_contact` is the authoritative flag. The list payloads\n carry neither, so a card built from `pull_leads` / `pull_followups` can only\n ever show company channels. Say "enrich to reveal" rather than implying the\n contact is callable.\n\n Two things that look like reachability and are not: a `linkedin_page` alone\n (the rep cannot message a URL without leaving the artifact \u2014 same rule\n `research_lead_by_id` applies), and `contacts_count > 0` (it counts known\n people, not people you can contact; a lead can show 2518 contacts and zero\n channels). `pull_followups` carries `has_phone` as a ready-made boolean;\n `pull_leads` omits it, so derive from `phone_numbers` there.\n4. **Why it fits** \u2014 one sentence, \u226420 words. Walk this chain and stop at the\n first hit:\n\n 1. `short_description`\n 2. `description` (longer; only on `research_lead_by_id` /\n `research_lead_by_name_fuzzy` \u2014 the trim payloads omit it)\n 3. top 2 `tags[].display_name`\n 4. `qualification_summary.best_response_excerpt`, trimmed to one sentence\n 5. `keywords`, first 3, joined with ` \xB7 `\n 6. the resolved sector label \u2014 better than nothing, and if step 3 already\n printed the sector on the firmographics line, skip to step 7\n 7. the literal *"No description yet \u2014 run qualification to generate one"*\n\n Never leave this line blank: a silent gap reads as a rendering bug, whereas\n the fallback tells the rep the data is missing and what fixes it.\n\n **The two list payloads are complementary, so the chain must span both.**\n `pull_leads` returns `short_description` on every lead but no `sector_id`;\n `pull_followups` returns `sector_id` but no `short_description` at all. A\n card fed by one will fall through to a different step than the same card fed\n by the other \u2014 that is expected, not a bug. Never call\n `research_lead_by_id` per row just to fill this line: it is one request per\n lead. Fetch it lazily when the rep expands a card.\n\n**Never show** on a card: `id`, `sector_id`, `location.pos`, `location.country`\n(unless city and state are both missing), `is_hq`, `*_in_progress`,\n`highlighted_fields`, `custom_fields`, `stale_at`, `deal_insights`,\n`need_attention*`, any count that is 0, any value that is the string `"null"`.\n\n**Minimum actions.** A card that only displays is a table row that costs more \u2014\nif you are not wiring an action, render the markdown table instead. Wire at\nleast one write, and prefer the set the rep actually needs:\n\n| Card is for | Wire |\n|---|---|\n| triage a discovery batch | `lb.like` / `lb.dislike` + `lb.setStatus` |\n| working a call list | `lb.outreach` (gated on a note) + `lb.leadHistory` |\n| pipeline review | `lb.setStatus` + `lb.note` |\n\nAlways render the `.error` branch of every view-model \u2014 a control that cannot\nreach the host must say so, not sit silent.\n\n## Recipe: cold-call sheet (one row per lead)\n\n```js\nconst lb = window.LeadbayArtifacts; lb.configure();\nconst ASK = "<the user\'s request>";\n\nconst list = lb.callList({ source: "campaign", campaignId: CID, ask: ASK });\nlist.subscribe((l) => renderRows(l.items, l.loading)); // your render\n\n// per lead row (call when you build a row):\nfunction wireRow(lead, els) {\n const status = lb.field({ value: "STILL_CHASING" }); // static-enum <select>\n const note = lb.field({ validate: (v) => (v && v.trim() ? null : "Add a note") });\n lb.bindValue(els.status, status);\n lb.bindValue(els.note, note);\n lb.bindAction(els.log, lb.outreach({ leadId: lead.id, ask: ASK, status, note }));\n lb.bindAction(els.like, lb.like(lead.id));\n\n const history = lb.leadHistory(lead.id, ASK); // lazy\n history.subscribe((h) => renderHistory(els.history, h));\n els.expand.onclick = () => history.load(); // load on click\n}\n```\n\n## Recipe: lead-status dropdown (Wanted / Won / Lost)\n\nThe org-wide CRM status, as a `<select>` + Apply button. You write the markup;\n`lb.leadStatus` fills the options and holds the value, `lb.setStatus` does the write.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title">Acme Corp</span>\n <span class="lb-chips">\n <span id="taste" class="lb-chip" data-taste="liked">Liked</span>\n <span id="crm" class="lb-chip" data-status="WANTED">Wanted</span>\n </span>\n </div>\n <div class="lb-row">\n <select id="st" class="lb-select"></select>\n <button id="go" class="lb-btn">Apply</button>\n <span id="msg" class="lb-msg"></span>\n </div>\n</div>\n```\n\n**Two badges, never one.** Taste (`liked`/`disliked`, from `lb.like`/`lb.dislike`)\nand CRM status (`WANTED`/`WON`/`LOST`/`UNWANTED`, from `lb.setStatus`) are\nindependent axes \u2014 a lead can be liked *and* lost. Collapsing them into a single\nchip destroys information: the rep can no longer see that a lead they liked went\nnowhere. Render `data-taste` and `data-status` as separate chips inside\n`lb-chips`, and hide the one that has no value rather than reusing it.\n\n```js\nlb.styles(); // once per artifact \u2014 see below\n\nconst status = lb.leadStatus(lead.org_lead_status); // seed with the CURRENT value\nconst save = lb.setStatus({ leadId: lead.id, status, ask: ASK });\n\nlb.bindSelect(document.getElementById("st"), status); // populates the 4 options\nlb.bindAction(document.getElementById("go"), save); // click \u2192 write\n\nsave.subscribe((a) => { // render your own feedback\n msg.textContent = a.loading ? "Saving\u2026"\n : a.error ? a.error.message // includes partial failures\n : a.lastResult ? `Set to ${a.lastResult.status}` : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nLoading / success / error styling comes free: `bindAction` and `bindSelect` set\n`data-lb-state` (`ready|loading|error|success|unavailable`) and the skin already\ntargets those attributes. No extra wiring.\n\nSave-on-change instead of an Apply button \u2014 drop `bindAction` and run it yourself:\n\n```js\ndocument.getElementById("st").addEventListener("change", () => save.run());\n```\n\n**Bulk apply** across checked rows \u2014 pass `leadIds` and a `confirm`, since one\nclick rewrites a field every rep in the org sees:\n\n```js\nconst bulk = lb.setStatus({\n leadIds: () => checkedIds, // \u2190 read at run() time, not at build time\n status, ask: ASK,\n confirm: "Set this status on every selected lead?",\n});\n```\n\n`leadIds` is read when the action runs, so a live selection works \u2014 but pass the\narray itself if your selection is fixed. A partial write (some leads rejected)\nsurfaces as `.error`, never as a green button: `setStatus` checks the `failed[]`\nthe tool returns.\n\nThe backend stamps the status date as "now" on every write, which is what a rep\nclicking a dropdown means. Don\'t add a date picker unless the user asks to\nbackdate \u2014 then pass an optional `date` field holding `YYYY-MM-DD`:\n`lb.setStatus({ leadId, status, date, ask })`.\n\n## Recipe: manager dashboard\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.loading) showSpinner();\n if (t.data) {\n renderLeaderboard(t.data.reps); // sorted by total_activities; cols: name, notes, meetings_or_interest, lost\u2026\n renderTrendChart(t.data.trend); // [{date,count}] \u2192 Chart.js (allowed from CDN)\n }\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\n## Recipe: live enrichment\n\n```js\nconst job = lb.enrichment({ leadIds: [LEAD], titles: ["CEO", "VP Sales"], ask: ASK });\njob.subscribe((j) => {\n const p = j.data && j.data.overall_progress; // {done,total,done_ratio}\n renderBar(p);\n if (j.done) renderContacts(j.data.leads); // enriched contacts\n});\nrefreshBtn.onclick = () => job.refresh();\n```\n\n## Write-call rules\n\nThe domain factories handle these for you. If you hand-roll an action:\n`leadbay_report_outreach` args MUST include `verification:{source:"user_confirmed", ref}`\nAND `_triggered_by`; `leadbay_add_leads_to_campaign` needs `_triggered_by`;\n`add_note`/`like_lead`/`dislike_lead` take only their own args. `epilogue_status` is\none of `lb.EPILOGUE_STATUSES`. Snoozing (pushback) is advanced-gated \u2014 not\ncallable from a default artifact. Org lead status IS on the default surface:\nuse `lb.setStatus`, which owns the arg shape AND the partial-write check \u2014\n`leadbay_set_lead_status` writes each lead separately, so it can resolve 200\nwith a non-empty `failed[]`. Hand-rolling that action will report a green\nbutton over a write that never landed.\n\n## Degradation + live updates\n\nIf the host bridge is absent, a view-model\'s `.error` is set with `.error.unavailable\n=== true` (bind helpers set `data-lb-state="unavailable"`) \u2014 nothing throws. Every\ncall also has a **30s timeout** (configurable via `lb.configure({ timeoutMs })`): a\nhost call that never settles becomes `.error` with `code:"timeout"`, so a control is\nnever stuck loading forever \u2014 always render the `.error` branch so the user can retry.\nAuto-poll (`pollEvery`) depends on the cowork host serving FRESH reads; `.refresh()`\nis the guaranteed manual path \u2014 always wire a Refresh control for polling resources.';
25471
26414
  }
25472
26415
  });
25473
26416
 
@@ -25650,6 +26593,7 @@ __export(dist_exports, {
25650
26593
  sendFeedback: () => sendFeedback,
25651
26594
  setActiveLens: () => setActiveLens,
25652
26595
  setEpilogueStatus: () => setEpilogueStatus,
26596
+ setLeadStatus: () => setLeadStatus,
25653
26597
  setPushback: () => setPushback,
25654
26598
  setQualificationQuestions: () => setQualificationQuestions,
25655
26599
  setTelemetry: () => setTelemetry,
@@ -25726,6 +26670,7 @@ var init_dist = __esm({
25726
26670
  init_delete_custom_field();
25727
26671
  init_like_lead();
25728
26672
  init_dislike_lead();
26673
+ init_set_lead_status();
25729
26674
  init_set_telemetry();
25730
26675
  init_add_contact();
25731
26676
  init_remove_contact();
@@ -25977,6 +26922,13 @@ var init_dist = __esm({
25977
26922
  // to the agent without requiring LEADBAY_MCP_ADVANCED=1.
25978
26923
  likeLead,
25979
26924
  dislikeLead,
26925
+ // Org-wide CRM status (WANTED/WON/LOST/UNWANTED). Granular-shaped but
26926
+ // registered HERE, not in granularWriteTools: reps state deal outcomes in
26927
+ // ordinary conversation, and the artifact-kit status dropdown calls it —
26928
+ // both need it on the default surface without LEADBAY_MCP_ADVANCED=1.
26929
+ // Distinct from setEpilogueStatus (outreach disposition), which stays
26930
+ // advanced-gated.
26931
+ setLeadStatus,
25980
26932
  // Campaign write composites — persist a hand-picked cohort of leads.
25981
26933
  // Backend POST endpoints; gated behind LEADBAY_MCP_WRITE=1 in MCP.
25982
26934
  createCampaign,
@@ -27161,7 +28113,7 @@ Build the final mappings yourself. Start from \`leadbay_resolve_import_rows.mapp
27161
28113
 
27162
28114
  # PHASE 5 \u2014 QUALIFY (optional) + REPORT
27163
28115
 
27164
- Prefer \`leadbay_import_and_qualify\` when the user asks to qualify/research after import; otherwise use \`leadbay_import_leads\`. For large files or short client timeouts, pass \`wait_for_completion=false\` and poll \`leadbay_import_status\`. After import, qualify only lead IDs returned by the import. Rows that came back \`uncrawled\` are pending a background crawl (not failures); the leads Leadbay adds for them populate in the user's Leadbay account as the crawl completes \u2014 tell the user that, not that a tool call will fetch them (\`import_status\` refreshes status/progress only; \`pull_leads\` reads the active lens, so an imported lead outside it may not appear; re-running the import later re-reconciles those companies).
28116
+ Prefer \`leadbay_import_and_qualify\` when the user asks to qualify/research after import; otherwise use \`leadbay_import_leads\`. For large files or short client timeouts, pass \`wait_for_completion=false\` and poll \`leadbay_import_status\`. After import, qualify only lead IDs returned by the import. Rows that came back \`uncrawled\` are pending a background crawl (not failures); the leads Leadbay adds for them populate in the user's Leadbay account as the crawl completes \u2014 tell the user that, not that a tool call will fetch them (\`import_status\` reports the rows the wizard already placed, not leads a later crawl adds; \`pull_leads\` reads the active lens, so an imported lead outside it may not appear; re-running the import later re-reconciles those companies).
27165
28117
 
27166
28118
  **Deliver the augmented file back to the user**: the original file plus a new \`LEADBAY_ID\` column populated from the resolution step. This is the second deliverable of a job well done.
27167
28119
 
@@ -27726,10 +28678,17 @@ Research the company name or domain '{{arg:domain}}' for me using Leadbay.
27726
28678
 
27727
28679
  # PHASE 1 \u2014 RESOLVE + DEEP DIVE
27728
28680
  Call \`leadbay_research_lead_by_name_fuzzy\` with
27729
- \`companyName:'{{arg:domain}}'\`. Omit \`lensId\`: the default search deliberately
27730
- covers the user's visible Discover, Monitor, and Activate corpus, including
27731
- other lenses and leads outside the active lens's first page. The composite
27732
- resolves the lead and returns the full deep-research payload in one call.
28681
+ \`companyName:'{{arg:domain}}'\`. If the user gave a domain or a contact email
28682
+ anywhere in the conversation, also pass \`website\` (or \`email\`) \u2014 that is the
28683
+ match key that finds a company they do not own yet. Omit \`lensId\`: the default
28684
+ search covers the user's visible Discover, Monitor, and Activate corpus \u2014
28685
+ including other lenses and leads outside the active lens's first page \u2014 and
28686
+ then the Leadbay company registry. The composite resolves the lead and returns
28687
+ the full deep-research payload in one call.
28688
+
28689
+ If it returns \`{resolution:"ambiguous"}\`, several companies match. Ask the user
28690
+ which one via \`ask_user_input_v0\`, then call \`leadbay_research_lead_by_id\` with
28691
+ the leadId they pick.
27733
28692
 
27734
28693
  Render the result using the canonical single-record card layout \u2014 detect MODE A
27735
28694
  (Discovery) since the user asked to research a company rather than prepare
@@ -27806,10 +28765,14 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
27806
28765
 
27807
28766
 
27808
28767
  # PHASE 2 \u2014 NOT FOUND
27809
- If the resolver returns \`LEAD_NOT_FOUND\`, say that the existing visible corpus
27810
- was searched. **Do NOT call \`leadbay_import_and_qualify\` automatically.** Offer
27811
- to import and qualify the company as a separate, explicit next step; only call
27812
- it after the user agrees.
28768
+ If the resolver returns \`LEAD_NOT_FOUND\`, read its hint: it names the field
28769
+ that would have found the company (\`would_help\`, usually \`website\`). **Ask the
28770
+ user for that field first** \u2014 "what's their website?" \u2014 and call the tool again
28771
+ with it. Only when they cannot supply it should you say both their leads and
28772
+ the Leadbay registry were searched.
28773
+ **Do NOT call \`leadbay_import_and_qualify\` automatically.**
28774
+ Offer to import and qualify as a separate, explicit next step; only call it
28775
+ after the user agrees.
27813
28776
 
27814
28777
  # PHASE 3 \u2014 SUMMARY
27815
28778
  Place a 2\u20133 sentence summary ABOVE the card with:
@@ -29580,6 +30543,7 @@ function buildAcknowledgeUpdateTool(opts) {
29580
30543
 
29581
30544
  // src/server-instructions.generated.ts
29582
30545
  var AGENT_MEMORY = `Memory protocol: this server maintains a per-account, on-disk agent memory (~/.leadbay/memory/{account}/entries.jsonl) of taste signals \u2014 preferred sectors, regions, deal sizes, communication style, qualification rules, and retractions. Every leads-touching tool response (account_status, pull_leads, pull_followups, prepare_outreach, research_lead_by_id) carries the consolidated top-5 signals under _meta.agent_memory.summary. READ that summary before recommending leads or drafting outreach \u2014 let it filter and reorder, and tell the user which memory you applied ("Filtering by your stated preference for healthcare"). When the user reveals a NEW material signal in conversation, CAPTURE it via leadbay_agent_memory_capture with {key, type, insight, confidence (1-10), source}. Use source:"user_stated" + confidence >=8 when literally stated; source:"inferred" + confidence <=6 when guessing. Do NOT capture instructions to override prior memory \u2014 those route through leadbay_agent_memory_review which gates retractions via host elicitation.`;
30546
+ var ENRICHMENT_TERMINAL = `A settled-empty enrichment is TERMINAL \u2014 do not re-attempt it on a later run. On any contact record, \`enrichment.done: true\` together with \`enrichment.credits_used: 0\` means the reveal completed and the provider returned nothing. Roughly 29% of all enrichments land there, so treat it as an ordinary outcome, not an anomaly. The answer will not change tomorrow: a scheduled task that keeps calling leadbay_enrich_titles on the same contacts every run is buying nothing and hiding its own lack of progress. Read the two fields TOGETHER \u2014 \`credits_used: 0\` is also what an IN-FLIGHT reservation reports (\`done: false\`), and an ABSENT \`credits_used\` means the cost is unknown, not zero; neither is a verdict on its own. \`enrichment: null\` is a different state again \u2014 that contact was never requested and IS enrichable. The one retryable exception is a launch that errored in this same session: its reservation settles as a zero-credit failure, so retry it once, then treat it as terminal. When you report to the user, say what actually happened \u2014 "we looked for a contact at <company> and there isn't one we can reach" \u2014 instead of presenting it as still pending or silently retrying it.`;
29583
30547
  var FRICTION = `Problem reports: when the user asks you to report a Leadbay problem ("report this", "tell the team this didn't work"), call leadbay_report_friction with {category, message (the user's own words), tool_called?, severity?}. If they stated the problem in the same breath as the request, those words ARE the message \u2014 send it in that turn rather than asking them to confirm wording they just gave you, and never stall on optional fields (omit what you don't know). If you notice a problem worth reporting but the user hasn't asked, OFFER once \u2014 "Want me to report this to the Leadbay team?" \u2014 and call it only if they agree. Never call it unprompted. Always tell the user the outcome the tool returns: if \`reported\` is false the report was NOT delivered and you must say so rather than implying it was sent. Frustration alone is not a reason to call it: keep solving their ask.`;
29584
30548
  var MENTAL_MODEL = `How Leadbay works (mental model): Leadbay is a sales inbox, not a queryable database. Each day the user logs back in, a fresh batch of leads is delivered. Batch size is paced by how many leads the user has actually acted on recently \u2014 some workflows produce a big stream of smaller prospects, others a narrow stream of bigger ones. Pulling more won't produce more; the user acting on leads (outreach, skips, saves) does.`;
29585
30549
  var QUOTA_TOPUP = `Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options \u2014 wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY \u2014 they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' \u2014 when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits \u2014 even WITHOUT re-calling account_status \u2014 treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep.
@@ -29729,6 +30693,9 @@ function buildServerInstructions(exposed) {
29729
30693
  parts.push(TRIGGERED_BY);
29730
30694
  parts.push(MENTAL_MODEL);
29731
30695
  parts.push(QUOTA_TOPUP);
30696
+ if (has("leadbay_enrich_titles")) {
30697
+ parts.push(ENRICHMENT_TERMINAL);
30698
+ }
29732
30699
  parts.push(TRANSIENT_401);
29733
30700
  parts.push(buildScoringParagraph(has));
29734
30701
  parts.push(FIRST_RUN_ROUTING);
@@ -31871,7 +32838,7 @@ var OAUTH_BASE_URLS = {
31871
32838
  fr: "https://staging.api.leadbay.app"
31872
32839
  }
31873
32840
  };
31874
- var VERSION = "0.31.0";
32841
+ var VERSION = "0.32.0";
31875
32842
  var HELP = `
31876
32843
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
31877
32844