@leadbay/mcp 0.31.1 → 0.32.1

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.
@@ -1167,7 +1167,7 @@ Build the final mappings yourself. Start from \`leadbay_resolve_import_rows.mapp
1167
1167
 
1168
1168
  # PHASE 5 \u2014 QUALIFY (optional) + REPORT
1169
1169
 
1170
- 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).
1170
+ 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).
1171
1171
 
1172
1172
  **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.
1173
1173
 
@@ -1732,10 +1732,17 @@ Research the company name or domain '{{arg:domain}}' for me using Leadbay.
1732
1732
 
1733
1733
  # PHASE 1 \u2014 RESOLVE + DEEP DIVE
1734
1734
  Call \`leadbay_research_lead_by_name_fuzzy\` with
1735
- \`companyName:'{{arg:domain}}'\`. Omit \`lensId\`: the default search deliberately
1736
- covers the user's visible Discover, Monitor, and Activate corpus, including
1737
- other lenses and leads outside the active lens's first page. The composite
1738
- resolves the lead and returns the full deep-research payload in one call.
1735
+ \`companyName:'{{arg:domain}}'\`. If the user gave a domain or a contact email
1736
+ anywhere in the conversation, also pass \`website\` (or \`email\`) \u2014 that is the
1737
+ match key that finds a company they do not own yet. Omit \`lensId\`: the default
1738
+ search covers the user's visible Discover, Monitor, and Activate corpus \u2014
1739
+ including other lenses and leads outside the active lens's first page \u2014 and
1740
+ then the Leadbay company registry. The composite resolves the lead and returns
1741
+ the full deep-research payload in one call.
1742
+
1743
+ If it returns \`{resolution:"ambiguous"}\`, several companies match. Ask the user
1744
+ which one via \`ask_user_input_v0\`, then call \`leadbay_research_lead_by_id\` with
1745
+ the leadId they pick.
1739
1746
 
1740
1747
  Render the result using the canonical single-record card layout \u2014 detect MODE A
1741
1748
  (Discovery) since the user asked to research a company rather than prepare
@@ -1812,10 +1819,14 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
1812
1819
 
1813
1820
 
1814
1821
  # PHASE 2 \u2014 NOT FOUND
1815
- If the resolver returns \`LEAD_NOT_FOUND\`, say that the existing visible corpus
1816
- was searched. **Do NOT call \`leadbay_import_and_qualify\` automatically.** Offer
1817
- to import and qualify the company as a separate, explicit next step; only call
1818
- it after the user agrees.
1822
+ If the resolver returns \`LEAD_NOT_FOUND\`, read its hint: it names the field
1823
+ that would have found the company (\`would_help\`, usually \`website\`). **Ask the
1824
+ user for that field first** \u2014 "what's their website?" \u2014 and call the tool again
1825
+ with it. Only when they cannot supply it should you say both their leads and
1826
+ the Leadbay registry were searched.
1827
+ **Do NOT call \`leadbay_import_and_qualify\` automatically.**
1828
+ Offer to import and qualify as a separate, explicit next step; only call it
1829
+ after the user agrees.
1819
1830
 
1820
1831
  # PHASE 3 \u2014 SUMMARY
1821
1832
  Place a 2\u20133 sentence summary ABOVE the card with:
@@ -2781,30 +2792,59 @@ function getPrompt(name, args = {}) {
2781
2792
 
2782
2793
  // ../core/dist/client.js
2783
2794
  import https from "https";
2795
+ import { AsyncLocalStorage } from "async_hooks";
2784
2796
  import { readdirSync, readFileSync, existsSync } from "fs";
2785
2797
  import { join } from "path";
2786
2798
  var LENS_CACHE_TTL_MS = 5 * 60 * 1e3;
2787
2799
  var TASTE_CACHE_TTL_MS = 10 * 60 * 1e3;
2788
2800
  var ME_CACHE_TTL_MS = 60 * 1e3;
2789
2801
  var MAX_CONCURRENT = 5;
2802
+ var DEFAULT_REQUEST_TIMEOUT_MS = 6e5;
2803
+ function defaultTimeoutMs() {
2804
+ const raw = process.env.LEADBAY_TIMEOUT_MS;
2805
+ if (raw === void 0 || raw.trim() === "")
2806
+ return DEFAULT_REQUEST_TIMEOUT_MS;
2807
+ const n = Number(raw);
2808
+ return Number.isFinite(n) ? n : DEFAULT_REQUEST_TIMEOUT_MS;
2809
+ }
2810
+ var requestSignalStore = new AsyncLocalStorage();
2811
+ function runWithRequestSignal(signal, fn) {
2812
+ return requestSignalStore.run(signal, fn);
2813
+ }
2814
+ function makeCancelledError(method, url) {
2815
+ const err = new Error(`Request cancelled: ${method} ${url}`);
2816
+ err.name = "AbortError";
2817
+ err.code = "CANCELLED";
2818
+ return err;
2819
+ }
2790
2820
  var REGIONS = {
2791
2821
  us: "https://api-us.leadbay.app",
2792
2822
  fr: "https://api-fr.leadbay.app"
2793
2823
  };
2794
2824
  var API_VERSION = "1.6";
2795
2825
  var API_PREFIX = `/${API_VERSION}`;
2796
- function httpsRequest(method, url, headers, body, timeoutMs) {
2826
+ function httpsRequest(method, url, headers, body, timeoutMs, signal) {
2827
+ const deadlineMs = timeoutMs ?? defaultTimeoutMs();
2828
+ const abortSignal = signal ?? requestSignalStore.getStore();
2829
+ const abortSafe = method.toUpperCase() === "GET";
2797
2830
  return new Promise((resolve, reject) => {
2798
2831
  const start = Date.now();
2832
+ if (abortSignal?.aborted) {
2833
+ reject(makeCancelledError(method, url));
2834
+ return;
2835
+ }
2799
2836
  const parsed = new URL(url);
2800
2837
  const reqHeaders = { ...headers };
2801
2838
  if (body !== void 0) {
2802
2839
  reqHeaders["Content-Length"] = Buffer.byteLength(body);
2803
2840
  }
2804
2841
  let deadline;
2842
+ let onAbort;
2805
2843
  const clearDeadline = () => {
2806
2844
  if (deadline !== void 0)
2807
2845
  clearTimeout(deadline);
2846
+ if (onAbort)
2847
+ abortSignal?.removeEventListener("abort", onAbort);
2808
2848
  };
2809
2849
  const req = https.request({
2810
2850
  hostname: parsed.hostname,
@@ -2825,15 +2865,24 @@ function httpsRequest(method, url, headers, body, timeoutMs) {
2825
2865
  });
2826
2866
  });
2827
2867
  });
2828
- if (timeoutMs !== void 0 && timeoutMs > 0) {
2868
+ if (deadlineMs > 0) {
2829
2869
  deadline = setTimeout(() => {
2830
2870
  req.destroy?.();
2831
- const err = new Error(`Request timed out after ${timeoutMs}ms: ${method} ${url}`);
2871
+ const err = new Error(`Request timed out after ${deadlineMs}ms: ${method} ${url}`);
2832
2872
  err.code = "TIMEOUT";
2873
+ err.timeout_ms = deadlineMs;
2833
2874
  reject(err);
2834
- }, timeoutMs);
2875
+ }, deadlineMs);
2835
2876
  deadline.unref?.();
2836
2877
  }
2878
+ if (abortSignal && abortSafe) {
2879
+ onAbort = () => {
2880
+ req.destroy?.();
2881
+ clearDeadline();
2882
+ reject(makeCancelledError(method, url));
2883
+ };
2884
+ abortSignal.addEventListener("abort", onAbort, { once: true });
2885
+ }
2837
2886
  req.on("error", (e) => {
2838
2887
  clearDeadline();
2839
2888
  reject(e);
@@ -3155,6 +3204,8 @@ var LeadbayClient = class _LeadbayClient {
3155
3204
  throw this.mapErrorResponse(res.status, res.body, path, res.headers);
3156
3205
  }
3157
3206
  return JSON.parse(res.body);
3207
+ } catch (e) {
3208
+ throw this.mapTransportError(e, `${method} ${path}`);
3158
3209
  } finally {
3159
3210
  this.releaseSemaphore();
3160
3211
  }
@@ -3186,6 +3237,8 @@ var LeadbayClient = class _LeadbayClient {
3186
3237
  if (res.status < 200 || res.status >= 300) {
3187
3238
  throw this.mapErrorResponse(res.status, res.body, path, res.headers);
3188
3239
  }
3240
+ } catch (e) {
3241
+ throw this.mapTransportError(e, `${method} ${path}`);
3189
3242
  } finally {
3190
3243
  this.releaseSemaphore();
3191
3244
  }
@@ -3223,6 +3276,8 @@ var LeadbayClient = class _LeadbayClient {
3223
3276
  throw this.mapErrorResponse(res.status, res.body, path, res.headers);
3224
3277
  }
3225
3278
  return JSON.parse(res.body);
3279
+ } catch (e) {
3280
+ throw this.mapTransportError(e, `${method} ${path}`);
3226
3281
  } finally {
3227
3282
  this.releaseSemaphore();
3228
3283
  }
@@ -3283,6 +3338,29 @@ var LeadbayClient = class _LeadbayClient {
3283
3338
  would_call: { method, path: fullPath, body: journalBody }
3284
3339
  };
3285
3340
  }
3341
+ /**
3342
+ * Turn httpsRequest's raw TIMEOUT rejection into the `{error:true, code, …}`
3343
+ * envelope every other failure already speaks, so the agent gets something it
3344
+ * can read out to the user and act on rather than a bare Error string. Any
3345
+ * other rejection (ECONNRESET, DNS, a mapped 4xx/5xx) passes through untouched
3346
+ * — this is a translation, not a catch-all.
3347
+ *
3348
+ * The code stays "TIMEOUT" so the hosted auth probe's existing branch
3349
+ * (auth-http.ts) keeps classifying it as a transient fault and moves to the
3350
+ * sibling region instead of declaring a live token expired.
3351
+ */
3352
+ mapTransportError(e, endpoint) {
3353
+ const err = e;
3354
+ if (err?.code !== "TIMEOUT")
3355
+ return e;
3356
+ const ms = err.timeout_ms ?? defaultTimeoutMs();
3357
+ const envelope = this.makeError("TIMEOUT", `Leadbay did not respond within ${ms}ms \u2014 the request was cancelled`, "The connection was accepted but no response came back, so this is a Leadbay-side stall, not a bad request. It is transient: retry the same call once. If it times out again, tell the user Leadbay is not responding right now and offer to report it with leadbay_report_friction.", endpoint);
3358
+ if (envelope._meta) {
3359
+ envelope._meta.timeout_ms = ms;
3360
+ envelope._meta.latency_ms = ms;
3361
+ }
3362
+ return envelope;
3363
+ }
3286
3364
  mapErrorResponse(status, rawBody, endpoint, headers) {
3287
3365
  let parsed;
3288
3366
  try {
@@ -3404,6 +3482,8 @@ var LeadbayClient = class _LeadbayClient {
3404
3482
  this.telemetryEnabledFromStamp = false;
3405
3483
  }
3406
3484
  return observed;
3485
+ } catch (e) {
3486
+ throw this.mapTransportError(e, "GET /users/me");
3407
3487
  } finally {
3408
3488
  this.releaseSemaphore();
3409
3489
  }
@@ -8575,7 +8655,7 @@ The model \u2014 two layers. Primitives: \`lb.field\` (value + API-populated opt
8575
8655
 
8576
8656
  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.
8577
8657
 
8578
- 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.
8658
+ 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.
8579
8659
 
8580
8660
  WHEN TO USE: the user asks for a clickable / interactive artifact, dashboard, or call sheet that DOES things (not just displays data).
8581
8661
 
@@ -9433,9 +9513,31 @@ WHEN NOT TO USE: to answer the question \u2014 use leadbay_answer_clarification.
9433
9513
  `;
9434
9514
  var leadbay_get_contacts = `Get contacts for a lead, including enriched email and phone data. Returns both organization contacts and enrichable contacts with IDs, tagged with \`source:'org'|'paid'\`.
9435
9515
 
9436
- 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.
9516
+ 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.
9437
9517
 
9438
9518
  WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id, which already includes enriched contacts in its return.
9519
+
9520
+ ## Reading \`contact.enrichment\`
9521
+
9522
+ \`enrichment\` is the per-contact reveal record. Four states. Read \`done\` and \`credits_used\` **together** \u2014 neither is a verdict on its own.
9523
+
9524
+ | \`enrichment\` | \`done\` | \`credits_used\` | Meaning | What to do |
9525
+ |---|---|---|---|---|
9526
+ | missing / \`null\` | \u2014 | \u2014 | Never requested. | Enrichable \u2014 launch it. |
9527
+ | present | \`false\` | any | Reservation in flight. | Poll. Do NOT re-launch. |
9528
+ | present | \`true\` | \`0\` | Settled, found nothing. | **Terminal. Stop.** |
9529
+ | present | \`true\` | \`> 0\` | Resolved. | The channel is on the org-source twin, not here \u2014 see below. |
9530
+
9531
+ **\`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.
9532
+
9533
+ Two ways to misread the pair:
9534
+
9535
+ - **\`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\`.
9536
+ - **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".
9537
+
9538
+ 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.
9539
+
9540
+ **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.
9439
9541
  `;
9440
9542
  var leadbay_get_enrichment_job_titles = `List the actual job titles present across the leads currently in the user's selection \u2014 the candidate set the user can ask to enrich.
9441
9543
 
@@ -9750,7 +9852,7 @@ WHEN NOT TO USE: discovery (use leadbay_pull_leads); single-lead deep dive (use
9750
9852
 
9751
9853
  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.
9752
9854
 
9753
- \`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.
9855
+ \`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.
9754
9856
 
9755
9857
  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\`.
9756
9858
 
@@ -9775,7 +9877,8 @@ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before yo
9775
9877
  **Header \u2014 single line, choose by status:**
9776
9878
 
9777
9879
  - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
9778
- - Running: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
9880
+ - Running, \`handle_id\` present: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
9881
+ - 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."\`
9779
9882
  - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
9780
9883
 
9781
9884
  Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
@@ -9824,7 +9927,9 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
9824
9927
 
9825
9928
  | Observation | Suggest | Calls |
9826
9929
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
9827
- | Status: running | "Check progress" | leadbay_import_status(handle_id) |
9930
+ | Status: running, \`handle_id\` present | "Check progress" | leadbay_import_status(handle_id) |
9931
+ | 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 |
9932
+ | \`rows_pending_upload\` present | "Import the rows that never got submitted" | leadbay_import_leads (that subset only) |
9828
9933
  | 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 |
9829
9934
  | 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 |
9830
9935
  | Ambiguous / unresolved rows present | "Resolve the ambiguous rows" | leadbay_resolve_import_rows(records, identity_mappings)|
@@ -9834,9 +9939,11 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
9834
9939
  `;
9835
9940
  var leadbay_import_leads = `Import leads into Leadbay's CRM via the file-import wizard. Returns stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead_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.
9836
9941
 
9942
+ 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.
9943
+
9837
9944
  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.
9838
9945
 
9839
- \`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.
9946
+ \`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.
9840
9947
 
9841
9948
  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.
9842
9949
 
@@ -9867,7 +9974,8 @@ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before yo
9867
9974
  **Header \u2014 single line, choose by status:**
9868
9975
 
9869
9976
  - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
9870
- - Running: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
9977
+ - Running, \`handle_id\` present: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
9978
+ - 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."\`
9871
9979
  - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
9872
9980
 
9873
9981
  Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
@@ -9916,7 +10024,9 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
9916
10024
 
9917
10025
  | Observation | Suggest | Calls |
9918
10026
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
9919
- | Status: running | "Check progress" | leadbay_import_status(handle_id) |
10027
+ | Status: running, \`handle_id\` present | "Check progress" | leadbay_import_status(handle_id) |
10028
+ | 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 |
10029
+ | \`rows_pending_upload\` present | "Import the rows that never got submitted" | leadbay_import_leads (that subset only) |
9920
10030
  | 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 |
9921
10031
  | 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 |
9922
10032
  | Ambiguous / unresolved rows present | "Resolve the ambiguous rows" | leadbay_resolve_import_rows(records, identity_mappings)|
@@ -9924,7 +10034,7 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
9924
10034
  | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
9925
10035
  | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
9926
10036
  `;
9927
- var 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.
10037
+ var 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.
9928
10038
 
9929
10039
  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).
9930
10040
 
@@ -9948,12 +10058,14 @@ After the status line, propose the obvious refresh / progress-check / recovery a
9948
10058
 
9949
10059
  Specifically for import status:
9950
10060
 
9951
- 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.**
10061
+ 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.**
9952
10062
 
9953
10063
  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.
9954
10064
 
9955
- - 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).
9956
- - 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.
10065
+ - 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.
10066
+ - 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.
10067
+ - 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.
10068
+ - 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.
9957
10069
  - 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.
9958
10070
  - 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.
9959
10071
  - 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.
@@ -9977,9 +10089,10 @@ How the OTHER reasons map to the "Need attention" bucket (see the render block a
9977
10089
 
9978
10090
  | Observation | Suggest | Calls |
9979
10091
  |--------------------------------------|------------------------------------------------------|--------------------------------|
10092
+ | Status: complete, \`result.leads\` present | "Qualify the imported leads" | leadbay_bulk_qualify_leads(result.leads[].leadId) |
9980
10093
  | Status: complete | "See the imported (matched) leads" | leadbay_pull_leads |
9981
10094
  | 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 |
9982
- | Status: running | "Check again in N minutes" | leadbay_import_status \u2014 re-call|
10095
+ | 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) |
9983
10096
  | Status: error / failed (true error) | "Diagnose the failure" | leadbay_resolve_import_rows |
9984
10097
  `;
9985
10098
  var leadbay_launch_bulk_enrichment = `Launch a bulk-enrichment job against the current selection. The backend requires \`email=true\` OR \`phone=true\` (both can be true). Returns 204 with no body \u2014 there is no bulk_id and no per-job status endpoint. Track results by polling individual leads via leadbay_get_contacts after ~60s; 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.
@@ -11511,35 +11624,38 @@ Trigger phrases: "look up <Company>", "research <Company>", "what do we know abo
11511
11624
 
11512
11625
  Do NOT use for: "picked row with leadId" \u2192 \`leadbay_research_lead_by_id\`; "draft outreach for <Contact>" \u2192 \`leadbay_prepare_outreach\`.
11513
11626
 
11514
- Prefer when: company name in prose and no Leadbay id yet
11627
+ Prefer when: a company name or domain in prose, no Leadbay id yet \u2014 always pass \`website\` if a domain was mentioned
11515
11628
 
11516
11629
  Examples that SHOULD invoke this tool:
11517
11630
  - "Look up Acme Corp for me."
11518
11631
  - "Find Initech in my pipeline."
11632
+ - "Who is Wink Lab? Their email is at @wink-lab.com."
11519
11633
 
11520
11634
  Examples that should NOT invoke this tool (sound similar, route elsewhere):
11521
11635
  - "Tell me about that lead I just picked."
11522
11636
  - "Draft outreach to Acme's CTO."
11637
+ - "Show me today's leads."
11523
11638
 
11524
11639
  ---
11525
11640
 
11526
- Resolves \`companyName\` across visible Discover, Monitor, and Activate leads,
11527
- then delegates to **leadbay_research_lead_by_id**. Supplying \`lensId\`
11528
- deliberately restricts the backend search to that lens. The result matches
11529
- \`_by_id\`, plus:
11641
+ Resolves across the user's visible Discover/Monitor/Activate leads AND the
11642
+ **Leadbay company registry** \u2014 so a company they do not own yet is still
11643
+ findable \u2014 then delegates to **leadbay_research_lead_by_id**.
11530
11644
 
11531
- - \`_meta.resolved_from\`: \`"companyName"\`
11532
- - \`_meta.resolved_query\`: the original query
11533
- - \`_meta.match_candidates[]\`: up to 4 \`{leadId, name, score}\` alternatives
11645
+ **Pass \`website\` whenever the user mentioned a domain** \u2014 the strongest match
11646
+ key. It survives a misspelled company name and is what turns "not in your
11647
+ list" into an answer. With only a contact email, pass \`email\`: the company
11648
+ domain is derived from it, consumer mailboxes ignored.
11534
11649
 
11535
- \`LEAD_NOT_FOUND\` identifies whether the complete visible corpus, an explicit
11536
- lens, or only a degraded active-lens fallback was searched.
11650
+ When the registry cannot pick one company it returns \`{resolution:
11651
+ "ambiguous", query, candidates:[{leadId, name, website, location, \u2026}]}\`
11652
+ instead of a card. Ask which one; never guess from \`score\`.
11537
11653
 
11538
- WHEN TO USE: for a company/domain/contact reference
11539
- without a \`lead_id\`. Offer \`_meta.match_candidates\` when present.
11654
+ \`LEAD_NOT_FOUND\` is not a dead end: its hint names the field that would have
11655
+ found it \u2014 \`website\` or \`registry_number\`, both params. Ask for it and call
11656
+ again. Do not offer an import before asking.
11540
11657
 
11541
- WHEN NOT TO USE: with a UUID; call
11542
- leadbay_research_lead_by_id directly.
11658
+ Offer \`_meta.match_candidates\` when present.
11543
11659
 
11544
11660
  ---
11545
11661
 
@@ -11678,6 +11794,10 @@ out?"\`
11678
11794
  | User is done with this lead | "Back to the inbox" | leadbay_pull_leads |
11679
11795
 
11680
11796
 
11797
+ When \`resolution\` is \`"ambiguous"\`, render no card: use \`ask_user_input_v0\`,
11798
+ ONE \`single_select\` question ("Which one?"), one short label per candidate
11799
+ combining \`name\` and \`location\`.
11800
+
11681
11801
  When \`_meta.match_candidates\` is non-empty, prepend one extra NEXT STEPS row:
11682
11802
 
11683
11803
  | Observation | Suggest | Calls |
@@ -12072,6 +12192,84 @@ WHEN TO USE: low-level.
12072
12192
 
12073
12193
  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.
12074
12194
 
12195
+ 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\`.
12196
+ `;
12197
+ var leadbay_set_lead_status = `## WHEN TO USE
12198
+
12199
+ 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".
12200
+
12201
+ **Memory:** recall + capture via \`leadbay_agent_memory_*\` tools.
12202
+
12203
+ 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\`.
12204
+
12205
+ 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
12206
+
12207
+ Examples that SHOULD invoke this tool:
12208
+ - "We just signed Acme Corp \u2014 mark them as won."
12209
+ - "Mark these three as lost, they went with a competitor."
12210
+ - "Add Northwind to my wanted list, they're a priority target."
12211
+
12212
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
12213
+ - "I emailed the CTO this morning, log it."
12214
+ - "Thumbs up on this one, show me more like it."
12215
+ - "Snooze this lead until next quarter."
12216
+
12217
+ ## RENDER (quick)
12218
+
12219
+ One short confirmation line per status applied ("\u2705 **Acme Corp** \u2192 WON
12220
+ (closed 2026-03-14)"). If \`failed\` is non-empty, list those leads with
12221
+ their error underneath. Don't re-render the full lead card.
12222
+
12223
+ ---
12224
+
12225
+ Set the **org-wide CRM lead status** \u2014 the same field the Leadbay website's status
12226
+ selector writes, and the one a CSV import maps via \`mappings.statuses\`. It is shared
12227
+ across the whole organization: every rep sees the value this call sets.
12228
+
12229
+ Two distinct status systems exist in Leadbay. Do not confuse them:
12230
+
12231
+ | System | Values | Written by | Means |
12232
+ |---|---|---|---|
12233
+ | **Lead status** (this tool) | \`WANTED\` \`WON\` \`LOST\` \`UNWANTED\` (plus system-set \`DEFAULT\`, \`INBOUND\`) | this tool, CSV import | Commercial/pipeline outcome, org-wide |
12234
+ | **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 |
12235
+
12236
+ A deal outcome is a **lead status**. "She didn't pick up" is an **epilogue status**.
12237
+ Setting one never sets the other \u2014 when the user reports both in one breath ("called
12238
+ them, they signed"), make both calls.
12239
+
12240
+ ## Parameters
12241
+
12242
+ - \`lead_ids\` (required) \u2014 1\u2013200 lead UUIDs. Every lead gets the same status.
12243
+ - \`status\` (required) \u2014 one of \`WANTED\`, \`WON\`, \`LOST\`, \`UNWANTED\`. Accepted
12244
+ case-insensitively (\`won\` \u2192 \`WON\`); no synonyms are guessed, so "closed-won"
12245
+ or "dead" are rejected rather than silently mapped. \`DEFAULT\` and \`INBOUND\`
12246
+ are accepted but are normally set by Leadbay itself \u2014 don't offer them as
12247
+ user choices.
12248
+ - \`status_date\` (optional) \u2014 \`YYYY-MM-DD\`, the date the status was actually
12249
+ reached (a close date, the day the deal was lost). Omit it and the backend
12250
+ stamps now. Pass it whenever the user names a date; a deal closed last month
12251
+ stamped as today distorts every pipeline report.
12252
+
12253
+ ## Behaviour
12254
+
12255
+ Each lead is written individually (\`POST /leads/{leadId}/set_status\`, then
12256
+ \`POST /leads/{leadId}/set_status_date\` when \`status_date\` is given), so a partial
12257
+ failure is possible. The return is
12258
+ \`{ applied, count, status, status_date?, failed: [{lead_id, message}] }\` \u2014
12259
+ **always check \`failed\`** and report those leads to the user rather than claiming
12260
+ a clean sweep. \`applied\` is \`false\` when every lead failed.
12261
+
12262
+ Re-sending the same status is idempotent \u2014 no error, no duplicate entry.
12263
+
12264
+ WHEN TO USE: the user states a commercial outcome or pipeline
12265
+ position for specific leads: "we won them", "that one's dead", "these are my targets
12266
+ this quarter". Also use it from an artifact's status dropdown.
12267
+
12268
+ WHEN NOT TO USE: the user is reporting that an outreach
12269
+ *happened* (use \`leadbay_report_outreach\` \u2014 its \`epilogue_status\` covers the
12270
+ follow-up disposition), expressing taste rather than an outcome (\`leadbay_like_lead\`
12271
+ / \`leadbay_dislike_lead\`), or temporarily deferring a lead (\`leadbay_set_pushback\`).
12272
+
12075
12273
  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\`.
12076
12274
  `;
12077
12275
  var 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.
@@ -12827,6 +13025,52 @@ var getContacts = {
12827
13025
  required: ["leadId"],
12828
13026
  additionalProperties: false
12829
13027
  },
13028
+ outputSchema: {
13029
+ type: "object",
13030
+ properties: {
13031
+ contacts: {
13032
+ type: "array",
13033
+ description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
13034
+ items: {
13035
+ type: "object",
13036
+ properties: {
13037
+ id: { type: "string" },
13038
+ first_name: { type: ["string", "null"] },
13039
+ last_name: { type: ["string", "null"] },
13040
+ email: { type: ["string", "null"] },
13041
+ phone_number: { type: ["string", "null"] },
13042
+ linkedin_page: { type: ["string", "null"] },
13043
+ job_title: { type: ["string", "null"] },
13044
+ recommended: { type: "boolean" },
13045
+ source: { type: "string", enum: ["org", "paid"] },
13046
+ enrichment: {
13047
+ type: ["object", "null"],
13048
+ 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.",
13049
+ properties: {
13050
+ done: {
13051
+ type: "boolean",
13052
+ description: "False = reservation in flight. True = settled, either with a result (credits_used>0) or empty (credits_used:0). Per-contact, not per-channel."
13053
+ },
13054
+ credits_used: {
13055
+ type: "number",
13056
+ 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."
13057
+ },
13058
+ email_requested: { type: "boolean" },
13059
+ phone_requested: { type: "boolean" }
13060
+ }
13061
+ }
13062
+ },
13063
+ required: ["id", "source"]
13064
+ }
13065
+ },
13066
+ _fetch_errors: {
13067
+ type: "array",
13068
+ 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'.",
13069
+ items: { type: "object" }
13070
+ }
13071
+ },
13072
+ required: ["contacts"]
13073
+ },
12830
13074
  execute: async (client, params) => {
12831
13075
  const [orgResult, paidResult] = await Promise.allSettled([
12832
13076
  client.request("GET", `/leads/${params.leadId}/contacts?IncludeEnriched=true`),
@@ -15041,56 +15285,8 @@ async function refreshLeadStates(client, leadIds, questionOrder) {
15041
15285
 
15042
15286
  // ../core/dist/composite/import-leads.js
15043
15287
  import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
15044
- function isImportLeadsRunningResult(result) {
15045
- return "status" in result && result.status === "running";
15046
- }
15047
- var CHUNK_SIZE = 100;
15048
- var POLL_INTERVAL_MS2 = 2e3;
15049
- var DEFAULT_PER_PHASE_BUDGET_MS = 6e4;
15050
- var DEFAULT_TOTAL_BUDGET_MS = 3e5;
15051
- var STABILIZATION_POLLS = 2;
15052
- var MAX_COLUMN_NAME_LEN = 128;
15053
- var RESERVED_COLUMN_RE = /^mcp_row_id$/i;
15054
- var CUSTOM_FIELD_RE = /^CUSTOM\.(\d+)$/;
15055
- var IMPORT_RESOLVER_FIELDS = /* @__PURE__ */ new Set([
15056
- "LEADBAY_ID",
15057
- "CRM_ID",
15058
- "LEAD_NAME",
15059
- "LEAD_WEBSITE",
15060
- "SIREN"
15061
- ]);
15062
- function isCustomFieldMappingValue(v) {
15063
- return CUSTOM_FIELD_RE.test(v);
15064
- }
15065
- function customFieldIdOf(v) {
15066
- const m = CUSTOM_FIELD_RE.exec(v);
15067
- return m ? m[1] : null;
15068
- }
15069
- var PUBLIC_MAILBOX_DOMAINS = /* @__PURE__ */ new Set([
15070
- "gmail.com",
15071
- "googlemail.com",
15072
- "yahoo.com",
15073
- "ymail.com",
15074
- "outlook.com",
15075
- "hotmail.com",
15076
- "live.com",
15077
- "icloud.com",
15078
- "me.com",
15079
- "mac.com",
15080
- "aol.com",
15081
- "proton.me",
15082
- "protonmail.com",
15083
- "tutanota.com",
15084
- "gmx.com",
15085
- "gmx.net",
15086
- "gmx.de",
15087
- "mail.com",
15088
- "yandex.com",
15089
- "yandex.ru",
15090
- "qq.com",
15091
- "163.com",
15092
- "126.com"
15093
- ]);
15288
+
15289
+ // ../core/dist/composite/_import-records.js
15094
15290
  function normalizeDomain(input) {
15095
15291
  if (!input || typeof input !== "string")
15096
15292
  return null;
@@ -15121,6 +15317,216 @@ function normalizeDomain(input) {
15121
15317
  return null;
15122
15318
  return v;
15123
15319
  }
15320
+ var PUBLIC_MAILBOX_DOMAINS = /* @__PURE__ */ new Set([
15321
+ "gmail.com",
15322
+ "googlemail.com",
15323
+ "yahoo.com",
15324
+ "ymail.com",
15325
+ "outlook.com",
15326
+ "hotmail.com",
15327
+ "live.com",
15328
+ "icloud.com",
15329
+ "me.com",
15330
+ "mac.com",
15331
+ "aol.com",
15332
+ "proton.me",
15333
+ "protonmail.com",
15334
+ "tutanota.com",
15335
+ "gmx.com",
15336
+ "gmx.net",
15337
+ "gmx.de",
15338
+ "mail.com",
15339
+ "yandex.com",
15340
+ "yandex.ru",
15341
+ "qq.com",
15342
+ "163.com",
15343
+ "126.com",
15344
+ // Regional aliases of the same providers, plus the consumer ISP mailboxes
15345
+ // that dominate a French user base. Without these, `orange.fr` or
15346
+ // `yahoo.fr` reads as a company domain (codex review, mcp#188).
15347
+ "yahoo.fr",
15348
+ "yahoo.co.uk",
15349
+ "yahoo.es",
15350
+ "yahoo.it",
15351
+ "yahoo.de",
15352
+ "yahoo.ca",
15353
+ "yahoo.com.br",
15354
+ "yahoo.co.jp",
15355
+ "hotmail.fr",
15356
+ "hotmail.co.uk",
15357
+ "hotmail.es",
15358
+ "hotmail.it",
15359
+ "hotmail.de",
15360
+ "hotmail.be",
15361
+ "outlook.fr",
15362
+ "outlook.es",
15363
+ "outlook.de",
15364
+ "outlook.it",
15365
+ "live.fr",
15366
+ "live.be",
15367
+ "live.co.uk",
15368
+ "msn.com",
15369
+ "orange.fr",
15370
+ "wanadoo.fr",
15371
+ "free.fr",
15372
+ "sfr.fr",
15373
+ "laposte.net",
15374
+ "bbox.fr",
15375
+ "neuf.fr",
15376
+ "aliceadsl.fr",
15377
+ "numericable.fr",
15378
+ "club-internet.fr",
15379
+ "gmx.fr",
15380
+ "gmx.at",
15381
+ "gmx.ch",
15382
+ "web.de",
15383
+ "t-online.de",
15384
+ "libero.it",
15385
+ "wp.pl",
15386
+ "seznam.cz"
15387
+ ]);
15388
+ var MCP_ROW_ID_COLUMN = "MCP_ROW_ID";
15389
+ var 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;
15390
+ function ourRowId(raw) {
15391
+ if (raw == null)
15392
+ return void 0;
15393
+ const v = raw.trim();
15394
+ return MCP_ROW_ID_RE.test(v) ? v : void 0;
15395
+ }
15396
+ function cellNames(c) {
15397
+ return [c?.column_name, c?.key, c?.field].filter((n) => n != null && n !== "").map((n) => String(n).toLowerCase());
15398
+ }
15399
+ function readCell(record, key) {
15400
+ const want = key.toLowerCase();
15401
+ const arr = record.records;
15402
+ if (Array.isArray(arr)) {
15403
+ for (const c of arr) {
15404
+ if (cellNames(c).includes(want)) {
15405
+ const v = c?.value ?? null;
15406
+ return v != null ? String(v) : null;
15407
+ }
15408
+ }
15409
+ }
15410
+ const cells = record.cells;
15411
+ if (cells && typeof cells === "object" && !Array.isArray(cells)) {
15412
+ for (const [k, v] of Object.entries(cells)) {
15413
+ if (k.toLowerCase() === want) {
15414
+ return v != null ? String(v) : null;
15415
+ }
15416
+ }
15417
+ }
15418
+ if (Array.isArray(cells)) {
15419
+ for (const c of cells) {
15420
+ if (cellNames(c).includes(want)) {
15421
+ const v = c?.value ?? null;
15422
+ return v != null ? String(v) : null;
15423
+ }
15424
+ }
15425
+ }
15426
+ return null;
15427
+ }
15428
+ function recordMatchType(record) {
15429
+ return (record.match_type ?? record.matchType ?? "").toString().toUpperCase();
15430
+ }
15431
+ function isRecordTerminal(record) {
15432
+ const status = (record.status ?? "").toString().toUpperCase();
15433
+ return recordMatchType(record) === "NO_MATCH" || status === "IMPORTED";
15434
+ }
15435
+ function settlingDeficit(declaredTotal, fetched) {
15436
+ return Math.max(0, declaredTotal - fetched);
15437
+ }
15438
+ function reconcileRecords(records) {
15439
+ const leads = [];
15440
+ const not_imported = [];
15441
+ const pendingLeadIds = /* @__PURE__ */ new Set();
15442
+ let pending = 0;
15443
+ let distinct = 0;
15444
+ const seenRowIds = /* @__PURE__ */ new Set();
15445
+ for (const rec of records) {
15446
+ const rowId = ourRowId(readCell(rec, MCP_ROW_ID_COLUMN));
15447
+ const dedupeKey = rowId !== void 0 ? `row:${rowId}` : rec.id != null ? `rec:${String(rec.id)}` : null;
15448
+ if (dedupeKey !== null) {
15449
+ if (seenRowIds.has(dedupeKey))
15450
+ continue;
15451
+ seenRowIds.add(dedupeKey);
15452
+ }
15453
+ distinct++;
15454
+ const websiteCell = readCell(rec, "LEAD_WEBSITE");
15455
+ const domain = normalizeDomain(websiteCell ?? "") ?? normalizeDomain(rec.lead?.website ?? "") ?? void 0;
15456
+ if (!isRecordTerminal(rec)) {
15457
+ pending++;
15458
+ if (rec.lead?.id)
15459
+ pendingLeadIds.add(rec.lead.id);
15460
+ continue;
15461
+ }
15462
+ if (rec.lead?.id) {
15463
+ leads.push({
15464
+ ...rowId ? { rowId } : {},
15465
+ ...domain ? { domain } : {},
15466
+ leadId: rec.lead.id,
15467
+ name: rec.lead.name ?? null
15468
+ });
15469
+ continue;
15470
+ }
15471
+ if (recordMatchType(rec) === "NO_MATCH") {
15472
+ not_imported.push({
15473
+ ...rowId ? { rowId } : {},
15474
+ ...domain ? { domain } : {},
15475
+ reason: domain && PUBLIC_MAILBOX_DOMAINS.has(domain) ? "no_match" : "uncrawled"
15476
+ });
15477
+ continue;
15478
+ }
15479
+ pending++;
15480
+ }
15481
+ return { leads, not_imported, pending, distinct, pendingLeadIds };
15482
+ }
15483
+
15484
+ // ../core/dist/composite/_import-commit-log.js
15485
+ var MAX_ENTRIES = 500;
15486
+ var failures = /* @__PURE__ */ new Map();
15487
+ function recordCommitFailure(importId, reason) {
15488
+ if (failures.size >= MAX_ENTRIES) {
15489
+ const oldest = failures.keys().next().value;
15490
+ if (oldest !== void 0)
15491
+ failures.delete(oldest);
15492
+ }
15493
+ failures.set(importId, reason);
15494
+ }
15495
+ function commitFailureFor(importIds) {
15496
+ for (const id of importIds) {
15497
+ const reason = failures.get(id);
15498
+ if (reason !== void 0)
15499
+ return reason;
15500
+ }
15501
+ return void 0;
15502
+ }
15503
+
15504
+ // ../core/dist/composite/import-leads.js
15505
+ function isImportLeadsRunningResult(result) {
15506
+ return "status" in result && result.status === "running";
15507
+ }
15508
+ var CHUNK_SIZE = 100;
15509
+ var POLL_INTERVAL_MS2 = 2e3;
15510
+ var DEFAULT_PER_PHASE_BUDGET_MS = 6e4;
15511
+ var DEFAULT_TOTAL_BUDGET_MS = 3e5;
15512
+ var STABILIZATION_POLLS = 2;
15513
+ var MAX_COLUMN_NAME_LEN = 128;
15514
+ var RESERVED_COLUMN_RE = /^mcp_row_id$/i;
15515
+ var CUSTOM_FIELD_RE = /^CUSTOM\.(\d+)$/;
15516
+ var IMPORT_RESOLVER_FIELDS = /* @__PURE__ */ new Set([
15517
+ "LEADBAY_ID",
15518
+ "CRM_ID",
15519
+ "LEAD_NAME",
15520
+ "LEAD_WEBSITE",
15521
+ "SIREN"
15522
+ ]);
15523
+ function isCustomFieldMappingValue(v) {
15524
+ return CUSTOM_FIELD_RE.test(v);
15525
+ }
15526
+ function customFieldIdOf(v) {
15527
+ const m = CUSTOM_FIELD_RE.exec(v);
15528
+ return m ? m[1] : null;
15529
+ }
15124
15530
  function escapeCsvCell(raw) {
15125
15531
  if (raw == null)
15126
15532
  return "";
@@ -15174,6 +15580,19 @@ function importFingerprint(params, prep) {
15174
15580
  };
15175
15581
  return createHash3("sha256").update(stableStringify(payload)).digest("hex");
15176
15582
  }
15583
+ var ImportPhaseTimeout = class extends Error {
15584
+ phase;
15585
+ importId;
15586
+ budgetMs;
15587
+ code = "IMPORT_TIMEOUT";
15588
+ constructor(phase, importId, budgetMs) {
15589
+ super(`Import ${phase} phase did not finish within ${budgetMs}ms; the wizard is still running server-side. Poll leadbay_import_status with importIds=["${importId}"].`);
15590
+ this.phase = phase;
15591
+ this.importId = importId;
15592
+ this.budgetMs = budgetMs;
15593
+ this.name = "ImportPhaseTimeout";
15594
+ }
15595
+ };
15177
15596
  function checkAborted(signal) {
15178
15597
  if (signal?.aborted) {
15179
15598
  throw Object.assign(new Error("aborted"), { name: "AbortError" });
@@ -15200,37 +15619,6 @@ async function sleepWithAbort2(ms, signal) {
15200
15619
  signal.addEventListener("abort", onAbort, { once: true });
15201
15620
  });
15202
15621
  }
15203
- function readCell(record, key) {
15204
- const want = key.toLowerCase();
15205
- const arr = record.records;
15206
- if (Array.isArray(arr)) {
15207
- for (const c of arr) {
15208
- const k = (c?.column_name ?? c?.key ?? c?.field ?? "").toString().toLowerCase();
15209
- if (k === want) {
15210
- const v = c?.value ?? null;
15211
- return v != null ? String(v) : null;
15212
- }
15213
- }
15214
- }
15215
- const cells = record.cells;
15216
- if (cells && typeof cells === "object" && !Array.isArray(cells)) {
15217
- for (const [k, v] of Object.entries(cells)) {
15218
- if (k.toLowerCase() === want) {
15219
- return v != null ? String(v) : null;
15220
- }
15221
- }
15222
- }
15223
- if (Array.isArray(cells)) {
15224
- for (const c of cells) {
15225
- const k = (c?.key ?? c?.field ?? c?.column_name ?? "").toString().toLowerCase();
15226
- if (k === want) {
15227
- const v = c?.value ?? null;
15228
- return v != null ? String(v) : null;
15229
- }
15230
- }
15231
- }
15232
- return null;
15233
- }
15234
15622
  function validateColumnName(client, name, path) {
15235
15623
  if (typeof name !== "string" || name.length === 0) {
15236
15624
  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");
@@ -15489,7 +15877,7 @@ async function pollUntil(fn, done, budgetMs, signal, ctx, label) {
15489
15877
  async function pollPreprocess(client, importId, budgetMs, ctx, signal) {
15490
15878
  const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.pre_processing?.finished), budgetMs, signal, ctx, "preprocess");
15491
15879
  if (!result.pre_processing?.finished) {
15492
- 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}`);
15880
+ throw new ImportPhaseTimeout("preprocess", importId, budgetMs);
15493
15881
  }
15494
15882
  if (result.pre_processing.error) {
15495
15883
  throw client.makeError("IMPORT_PREPROCESS_FAILED", `Preprocess failed: ${result.pre_processing.error}`, `Check the input domains. importId=${importId} for backend debugging.`, `GET /imports/${importId}`);
@@ -15499,7 +15887,7 @@ async function pollPreprocess(client, importId, budgetMs, ctx, signal) {
15499
15887
  async function pollProcess(client, importId, budgetMs, ctx, signal) {
15500
15888
  const result = await pollUntil(() => client.request("GET", `/imports/${importId}`), (r) => Boolean(r.processing?.finished), budgetMs, signal, ctx, "process");
15501
15889
  if (!result.processing?.finished) {
15502
- 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}`);
15890
+ throw new ImportPhaseTimeout("process", importId, budgetMs);
15503
15891
  }
15504
15892
  if (result.processing.error != null) {
15505
15893
  throw client.makeError("IMPORT_PROCESSING_FAILED", `Backend processing failed: ${result.processing.error}`, `importId=${importId}.`, `GET /imports/${importId}`);
@@ -15526,10 +15914,7 @@ async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCoun
15526
15914
  records.push(...res.items);
15527
15915
  total = res.pagination.total ?? records.length;
15528
15916
  for (const r of res.items) {
15529
- const status = (r.status ?? "").toString().toUpperCase();
15530
- const matchType = (r.match_type ?? r.matchType ?? "").toString().toUpperCase();
15531
- const isTerminal = matchType === "NO_MATCH" || status === "IMPORTED";
15532
- if (!isTerminal)
15917
+ if (!isRecordTerminal(r))
15533
15918
  transient++;
15534
15919
  }
15535
15920
  const totalPages = res.pagination.pages ?? 0;
@@ -15557,14 +15942,15 @@ async function pollRecordsToTerminal(client, importId, budgetMs, expectedRowCoun
15557
15942
  }
15558
15943
  if (Date.now() >= deadline) {
15559
15944
  ctx?.logger?.warn?.(`import-leads: records did not stabilize (transient=${transient}, total=${total}); returning best-effort`);
15560
- 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`);
15945
+ throw new ImportPhaseTimeout("reconcile", importId, budgetMs);
15561
15946
  }
15562
15947
  await sleepWithAbort2(POLL_INTERVAL_MS2, signal);
15563
15948
  }
15564
15949
  }
15565
- async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId) {
15950
+ async function runOneChunk(client, chunk, chunkIdx, totalChunks, header, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onImportId, onNotificationId, onUploaded) {
15566
15951
  const upload = await uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx, onImportId);
15567
- return completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal);
15952
+ onUploaded?.(upload);
15953
+ return completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onNotificationId);
15568
15954
  }
15569
15955
  async function uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx, onImportId) {
15570
15956
  const csv = synthesizeCsv(header, chunk.map((c) => c.row));
@@ -15576,27 +15962,30 @@ async function uploadOneChunk(client, chunk, chunkIdx, totalChunks, header, ctx,
15576
15962
  onImportId(importId);
15577
15963
  return { importId, chunk, chunkIdx, totalChunks };
15578
15964
  }
15579
- async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal) {
15580
- const { importId, chunk } = upload;
15581
- const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
15582
- await pollPreprocess(client, importId, phaseBudget, ctx, signal);
15583
- ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
15584
- if (dryRun) {
15585
- return { importId, records: [], notification_id: null };
15586
- }
15587
- let updateMappingsResp = null;
15965
+ async function commitMappings(client, importId, mappings, ctx) {
15588
15966
  try {
15589
- updateMappingsResp = await client.request("POST", `/imports/${importId}/update_mappings`, mappings);
15967
+ const resp = await client.request("POST", `/imports/${importId}/update_mappings`, mappings);
15968
+ return resp?.notification_id ?? null;
15590
15969
  } catch (err) {
15591
15970
  if (err?.code === "API_ERROR" || err?.code === "NOT_FOUND") {
15592
15971
  ctx?.logger?.warn?.(`import-leads: update_mappings raw error (${err?.code}); retrying void`);
15593
15972
  await client.requestVoid("POST", `/imports/${importId}/update_mappings`, mappings);
15594
- } else {
15595
- throw err;
15973
+ return null;
15596
15974
  }
15975
+ throw err;
15976
+ }
15977
+ }
15978
+ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onNotificationId) {
15979
+ const { importId, chunk } = upload;
15980
+ const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
15981
+ await pollPreprocess(client, importId, phaseBudget, ctx, signal);
15982
+ ctx?.logger?.info?.(`import-leads: preprocess done for importId=${importId}`);
15983
+ if (dryRun) {
15984
+ return { importId, records: [], notification_id: null };
15597
15985
  }
15598
- const importNotificationId = updateMappingsResp?.notification_id ?? null;
15986
+ const importNotificationId = await commitMappings(client, importId, mappings, ctx);
15599
15987
  if (importNotificationId) {
15988
+ onNotificationId?.(importNotificationId);
15600
15989
  ctx?.logger?.info?.(`import-leads: notification_id=${importNotificationId} importId=${importId}`);
15601
15990
  }
15602
15991
  ctx?.logger?.info?.(`import-leads: mappings committed for importId=${importId}`);
@@ -15647,7 +16036,7 @@ function reconcileOneChunk(prep, chunk, matched, notImported) {
15647
16036
  }
15648
16037
  seenInputIndex.add(inputIdx);
15649
16038
  const inp = prep.validInputs[inputIdx];
15650
- const matchType = (rec.match_type ?? rec.matchType ?? "").toString();
16039
+ const matchType = recordMatchType(rec);
15651
16040
  if (rec.lead?.id) {
15652
16041
  matched.set(inputIdx, {
15653
16042
  domain: inp.outputDomain,
@@ -15842,7 +16231,20 @@ var importLeads = {
15842
16231
  },
15843
16232
  handle_id: {
15844
16233
  type: "string",
15845
- description: "Persisted UUID handle to pass to leadbay_import_status."
16234
+ 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)."
16235
+ },
16236
+ timed_out: {
16237
+ type: "boolean",
16238
+ 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."
16239
+ },
16240
+ rows_pending_upload: {
16241
+ type: "number",
16242
+ description: "Rows from later chunks that were never uploaded before the budget ran out. These are NOT running anywhere; re-import just those rows."
16243
+ },
16244
+ row_ids: {
16245
+ type: "array",
16246
+ 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.",
16247
+ items: { type: "string" }
15846
16248
  },
15847
16249
  progress: {
15848
16250
  type: "object",
@@ -15872,7 +16274,7 @@ var importLeads = {
15872
16274
  required: ["importIds", "region", "_meta"],
15873
16275
  anyOf: [
15874
16276
  { required: ["leads", "not_imported", "importIds", "region", "_meta"] },
15875
- { required: ["status", "handle_id", "importIds", "progress", "region", "_meta"] }
16277
+ { required: ["status", "importIds", "progress", "region", "_meta"] }
15876
16278
  ]
15877
16279
  },
15878
16280
  execute: async (client, params, ctx) => {
@@ -15999,23 +16401,33 @@ var importLeads = {
15999
16401
  const matched = /* @__PURE__ */ new Map();
16000
16402
  const notImported = /* @__PURE__ */ new Map();
16001
16403
  let cancelled = false;
16404
+ let timedOut = null;
16405
+ let rowsStarted = 0;
16406
+ const lastUpload = { current: null };
16002
16407
  const recordImportId = (id) => {
16003
16408
  if (!importIds.includes(id))
16004
16409
  importIds.push(id);
16005
16410
  };
16411
+ const recordNotificationId = (id) => {
16412
+ if (!notificationIds.includes(id))
16413
+ notificationIds.push(id);
16414
+ };
16006
16415
  try {
16007
16416
  for (let i = 0; i < chunks.length; i++) {
16008
16417
  const chunk = chunks[i];
16009
- const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId);
16010
- if (out.notification_id && !notificationIds.includes(out.notification_id)) {
16011
- notificationIds.push(out.notification_id);
16012
- }
16418
+ rowsStarted += chunk.length;
16419
+ const out = await runOneChunk(client, chunk, i, chunks.length, prep.header, prep.mappings, dryRun, perPhaseBudget, totalDeadline, ctx, signal, recordImportId, recordNotificationId, (u) => {
16420
+ lastUpload.current = u;
16421
+ });
16013
16422
  if (!dryRun) {
16014
16423
  reconcileOneChunk(prep, out, matched, notImported);
16015
16424
  }
16016
16425
  }
16017
16426
  } catch (err) {
16018
- if (err?.name === "AbortError") {
16427
+ if (err instanceof ImportPhaseTimeout) {
16428
+ timedOut = err;
16429
+ ctx?.logger?.warn?.(`import-leads: ${err.phase} budget exhausted after ${err.budgetMs}ms; returning status=running importIds=${importIds.join(",")}`);
16430
+ } else if (err?.name === "AbortError") {
16019
16431
  cancelled = true;
16020
16432
  ctx?.logger?.info?.(`import-leads: aborted via signal; importIds=${importIds.join(",")}`);
16021
16433
  } else if (err?.error === true) {
@@ -16030,9 +16442,54 @@ var importLeads = {
16030
16442
  throw err;
16031
16443
  }
16032
16444
  }
16445
+ if (timedOut) {
16446
+ if (timedOut.phase === "preprocess" && // A dry run is SUPPOSED to stop after preprocess — committing its
16447
+ // mappings would turn a validation pass into a real import.
16448
+ !dryRun && lastUpload.current && lastUpload.current.importId === timedOut.importId) {
16449
+ resumeParkedUpload(client, lastUpload.current, prep.mappings, ctx);
16450
+ }
16451
+ const rowsPendingUpload = prep.validInputs.length - rowsStarted;
16452
+ const malformed = prep.malformedDomains.map((d) => ({ domain: d, reason: "malformed" }));
16453
+ return {
16454
+ status: "running",
16455
+ timed_out: true,
16456
+ importIds,
16457
+ notification_ids: notificationIds,
16458
+ ...malformed.length > 0 ? { not_imported: malformed } : {},
16459
+ ...dryRun ? { dry_run: true } : {},
16460
+ ...prep.mode === "records" ? { row_ids: prep.validInputs.map((i) => i.rowId) } : {},
16461
+ progress: {
16462
+ phase: timedOut.phase,
16463
+ records_processed: matched.size,
16464
+ records_total: prep.validInputs.length
16465
+ },
16466
+ ...rowsPendingUpload > 0 ? { rows_pending_upload: rowsPendingUpload } : {},
16467
+ region: client.region,
16468
+ _meta: client.lastMeta ?? {
16469
+ region: client.region,
16470
+ endpoint: `GET /imports/${timedOut.importId}`,
16471
+ latency_ms: null,
16472
+ retry_after: null
16473
+ }
16474
+ };
16475
+ }
16033
16476
  return buildImportLeadsResult(client, prep, importIds, matched, notImported, dryRun, cancelled, notificationIds);
16034
16477
  }
16035
16478
  };
16479
+ var RESUME_COMMIT_BUDGET_MS = 10 * 6e4;
16480
+ function resumeParkedUpload(client, upload, mappings, ctx) {
16481
+ const bgCtx = { logger: ctx?.logger };
16482
+ const { importId } = upload;
16483
+ setTimeout(() => {
16484
+ void (async () => {
16485
+ await pollPreprocess(client, importId, RESUME_COMMIT_BUDGET_MS, bgCtx, void 0);
16486
+ await commitMappings(client, importId, mappings, bgCtx);
16487
+ })().then(() => ctx?.logger?.info?.(`import-leads: parked upload ${importId} committed; backend is processing`), (err) => {
16488
+ recordCommitFailure(importId, err?.message ?? err?.code ?? "mapping commit failed");
16489
+ ctx?.logger?.warn?.(`import-leads: parked upload ${importId} could not be committed (${err?.code ?? err?.message ?? "unknown"})`);
16490
+ });
16491
+ }, 0);
16492
+ }
16036
16493
  async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, handleId) {
16037
16494
  const tracker = ctx.bulkTracker;
16038
16495
  if (!tracker)
@@ -17858,6 +18315,125 @@ var dislikeLead = {
17858
18315
  }
17859
18316
  };
17860
18317
 
18318
+ // ../core/dist/tools/set-lead-status.js
18319
+ var statusPath = (leadId) => `/leads/${encodeURIComponent(leadId)}/set_status`;
18320
+ var statusDatePath = (leadId) => `/leads/${encodeURIComponent(leadId)}/set_status_date`;
18321
+ var statusBody = (status) => ({ status });
18322
+ var statusDateBody = (date) => ({ date: `${date}T00:00:00Z` });
18323
+ var MAX_LEADS = 200;
18324
+ var CONCURRENCY = 6;
18325
+ var LEAD_STATUS_SET2 = new Set(LEAD_STATUSES);
18326
+ var SETTABLE_LEAD_STATUSES = ["WANTED", "WON", "LOST", "UNWANTED"];
18327
+ var ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
18328
+ function messageOf(e) {
18329
+ if (e && typeof e === "object" && "message" in e)
18330
+ return String(e.message);
18331
+ return String(e);
18332
+ }
18333
+ async function writeAll(client, leadIds, status, statusDate) {
18334
+ const failed = [];
18335
+ let cursor = 0;
18336
+ async function worker() {
18337
+ for (; ; ) {
18338
+ const i = cursor++;
18339
+ if (i >= leadIds.length)
18340
+ return;
18341
+ const id = leadIds[i];
18342
+ try {
18343
+ await client.requestVoid("POST", statusPath(id), statusBody(status));
18344
+ if (statusDate) {
18345
+ await client.requestVoid("POST", statusDatePath(id), statusDateBody(statusDate));
18346
+ }
18347
+ } catch (e) {
18348
+ failed.push({ lead_id: id, message: messageOf(e) });
18349
+ }
18350
+ }
18351
+ }
18352
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, leadIds.length) }, () => worker()));
18353
+ return failed;
18354
+ }
18355
+ var setLeadStatus = {
18356
+ name: "leadbay_set_lead_status",
18357
+ annotations: {
18358
+ title: "Set lead CRM status",
18359
+ readOnlyHint: false,
18360
+ // Org-wide and overwrites whatever the last rep set — destructive in the
18361
+ // MCP sense (not reversible from the value we replaced).
18362
+ destructiveHint: true,
18363
+ idempotentHint: true,
18364
+ openWorldHint: true
18365
+ },
18366
+ description: leadbay_set_lead_status,
18367
+ optional: true,
18368
+ write: true,
18369
+ inputSchema: {
18370
+ type: "object",
18371
+ properties: {
18372
+ lead_ids: {
18373
+ type: "array",
18374
+ items: { type: "string" },
18375
+ description: `Lead UUIDs (1-${MAX_LEADS}). Every lead gets the same status.`
18376
+ },
18377
+ status: {
18378
+ type: "string",
18379
+ description: "One of: WANTED, WON, LOST, UNWANTED (case-insensitive). DEFAULT and INBOUND are accepted but are normally set by Leadbay itself."
18380
+ },
18381
+ status_date: {
18382
+ type: "string",
18383
+ description: "Optional YYYY-MM-DD \u2014 the date the status was actually reached (close date). Omit to let the backend stamp now."
18384
+ }
18385
+ },
18386
+ required: ["lead_ids", "status"],
18387
+ additionalProperties: false
18388
+ },
18389
+ execute: async (client, params) => {
18390
+ const leadIds = (params.lead_ids ?? []).filter((id) => typeof id === "string" && id.trim() !== "");
18391
+ if (leadIds.length === 0) {
18392
+ return {
18393
+ error: true,
18394
+ code: "BAD_INPUT",
18395
+ message: "lead_ids is empty",
18396
+ hint: "Pass at least one lead UUID."
18397
+ };
18398
+ }
18399
+ if (leadIds.length > MAX_LEADS) {
18400
+ return {
18401
+ error: true,
18402
+ code: "BAD_INPUT",
18403
+ message: `lead_ids has ${leadIds.length} entries, max is ${MAX_LEADS}`,
18404
+ hint: `Call leadbay_set_lead_status again per chunk of ${MAX_LEADS} lead_ids or fewer.`
18405
+ };
18406
+ }
18407
+ const status = String(params.status ?? "").trim().toUpperCase();
18408
+ if (!LEAD_STATUS_SET2.has(status)) {
18409
+ return {
18410
+ error: true,
18411
+ code: "BAD_INPUT",
18412
+ message: `Unknown lead status: ${JSON.stringify(params.status)}`,
18413
+ hint: `Use one of ${SETTABLE_LEAD_STATUSES.join(", ")} (case-insensitive).`
18414
+ };
18415
+ }
18416
+ const statusDate = params.status_date?.trim() || void 0;
18417
+ if (statusDate && !ISO_DATE.test(statusDate)) {
18418
+ return {
18419
+ error: true,
18420
+ code: "BAD_INPUT",
18421
+ message: `status_date ${JSON.stringify(params.status_date)} is not YYYY-MM-DD`,
18422
+ hint: "Pass a calendar date like 2026-03-14, or omit it to stamp now."
18423
+ };
18424
+ }
18425
+ const failed = await writeAll(client, leadIds, status, statusDate);
18426
+ const count = leadIds.length - failed.length;
18427
+ return {
18428
+ applied: count > 0,
18429
+ count,
18430
+ status,
18431
+ ...statusDate ? { status_date: statusDate } : {},
18432
+ failed
18433
+ };
18434
+ }
18435
+ };
18436
+
17861
18437
  // ../core/dist/tools/set-telemetry.js
17862
18438
  function isEnabled(telemetry_enabled) {
17863
18439
  return telemetry_enabled !== false;
@@ -18340,6 +18916,45 @@ var prepareOutreach = {
18340
18916
  }
18341
18917
  };
18342
18918
 
18919
+ // ../core/dist/lead-order.js
18920
+ var LEAD_ORDERS = [
18921
+ "SCORE:DESC",
18922
+ "SCORE:ASC",
18923
+ "NAME:ASC",
18924
+ "NAME:DESC",
18925
+ "SIZE:DESC",
18926
+ "SIZE:ASC",
18927
+ "SECTOR:ASC",
18928
+ "SECTOR:DESC",
18929
+ "STATUS:ASC",
18930
+ "STATUS:DESC",
18931
+ "CONTACT_COUNT:DESC",
18932
+ "CONTACT_COUNT:ASC",
18933
+ "LAST_PROSPECTING_ACTION_AT:DESC",
18934
+ "LAST_PROSPECTING_ACTION_AT:ASC",
18935
+ "EPILOGUE_STATUS_SET_AT:DESC",
18936
+ "EPILOGUE_STATUS_SET_AT:ASC",
18937
+ "LIKED:DESC",
18938
+ "DISLIKED:DESC"
18939
+ ];
18940
+ var LEAD_ORDER_SET = new Set(LEAD_ORDERS);
18941
+ function resolveLeadOrder(raw, tool) {
18942
+ const order = raw?.trim().toUpperCase();
18943
+ if (!order)
18944
+ return {};
18945
+ if (!LEAD_ORDER_SET.has(order)) {
18946
+ return {
18947
+ error: {
18948
+ error: true,
18949
+ code: "BAD_INPUT",
18950
+ message: `Unknown order: ${JSON.stringify(raw)}`,
18951
+ hint: `Call ${tool} again with one of: ${LEAD_ORDERS.join(", ")}.`
18952
+ }
18953
+ };
18954
+ }
18955
+ return { order };
18956
+ }
18957
+
18343
18958
  // ../core/dist/composite/_empty-lens-reason.js
18344
18959
  var CITY_LEVEL = 7;
18345
18960
  function criteriaOf(filter) {
@@ -18533,6 +19148,10 @@ var pullLeads = {
18533
19148
  },
18534
19149
  count: { type: "number", description: "Leads per page, max 50 (default 20)" },
18535
19150
  page: { type: "number", description: "Page number, 0-indexed (default 0)" },
19151
+ order: {
19152
+ type: "string",
19153
+ 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."
19154
+ },
18536
19155
  verbose: {
18537
19156
  type: "boolean",
18538
19157
  description: "If true, include the full set of lead-summary fields. Default false: returns the trimmed agent-friendly form."
@@ -18641,7 +19260,11 @@ var pullLeads = {
18641
19260
  const page = params.page ?? 0;
18642
19261
  const count = Math.min(params.count ?? 20, 50);
18643
19262
  const verbose = params.verbose ?? false;
18644
- const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true`);
19263
+ const resolvedOrder = resolveLeadOrder(params.order, "leadbay_pull_leads");
19264
+ if (resolvedOrder.error)
19265
+ return resolvedOrder.error;
19266
+ const orderQs = resolvedOrder.order ? `&order=${encodeURIComponent(resolvedOrder.order)}` : "";
19267
+ const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true${orderQs}`);
18645
19268
  const summaries = await Promise.all(res.items.map(async (lead) => {
18646
19269
  try {
18647
19270
  const r = await client.request("GET", `/leads/${lead.id}/ai_agent_responses`);
@@ -18910,6 +19533,10 @@ var pullFollowups = {
18910
19533
  type: "number",
18911
19534
  description: "Leads per page, max 200 (default 20)."
18912
19535
  },
19536
+ order: {
19537
+ type: "string",
19538
+ 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."
19539
+ },
18913
19540
  page: {
18914
19541
  type: "number",
18915
19542
  description: "Page number, 0-indexed (default 0)."
@@ -19019,7 +19646,7 @@ var pullFollowups = {
19019
19646
  if (params.city_id)
19020
19647
  geoTexts.push(params.city_id);
19021
19648
  if (geoTexts.length > 0) {
19022
- const { resolved, ambiguities } = await resolveLocations(client, geoTexts);
19649
+ const { resolved: resolved2, ambiguities } = await resolveLocations(client, geoTexts);
19023
19650
  if (ambiguities.length > 0) {
19024
19651
  return withAgentMemoryMeta(client, {
19025
19652
  status: "ambiguous_locations",
@@ -19034,8 +19661,8 @@ var pullFollowups = {
19034
19661
  }
19035
19662
  }, ctx);
19036
19663
  }
19037
- if (resolved.length > 0) {
19038
- effectiveSetFilter = mergeLocationIds(effectiveSetFilter, resolved);
19664
+ if (resolved2.length > 0) {
19665
+ effectiveSetFilter = mergeLocationIds(effectiveSetFilter, resolved2);
19039
19666
  }
19040
19667
  }
19041
19668
  if (effectiveSetFilter) {
@@ -19045,12 +19672,17 @@ var pullFollowups = {
19045
19672
  ctx?.logger?.warn?.(`pull_followups: POST /monitor/filter failed: ${err?.message ?? err?.code ?? err}`);
19046
19673
  }
19047
19674
  }
19675
+ const resolved = resolveLeadOrder(params.order, "leadbay_pull_followups");
19676
+ if (resolved.error)
19677
+ return resolved.error;
19678
+ const order = resolved.order;
19048
19679
  const qs = new URLSearchParams({
19049
19680
  personal: String(personal),
19050
19681
  liked: String(liked),
19051
19682
  filtered: String(filtered),
19052
19683
  count: String(count),
19053
- page: String(page)
19684
+ page: String(page),
19685
+ ...order ? { order } : {}
19054
19686
  }).toString();
19055
19687
  const [filterR, monitorR] = await Promise.allSettled([
19056
19688
  filtered ? client.request("GET", "/monitor/filter") : Promise.resolve(null),
@@ -20254,7 +20886,7 @@ var researchLeadById = {
20254
20886
  },
20255
20887
  _meta: {
20256
20888
  type: "object",
20257
- 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}].",
20889
+ 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}].",
20258
20890
  properties: {
20259
20891
  region: { type: "string" },
20260
20892
  lens_id: { type: "number" },
@@ -20266,6 +20898,10 @@ var researchLeadById = {
20266
20898
  type: ["array", "null"],
20267
20899
  items: { type: "object" }
20268
20900
  },
20901
+ resolved_matched_on: {
20902
+ type: ["array", "null"],
20903
+ items: { type: "string" }
20904
+ },
20269
20905
  agent_memory: { type: "object" }
20270
20906
  },
20271
20907
  // _meta is an open envelope: the MCP server layer injects
@@ -20438,7 +21074,8 @@ var researchLeadById = {
20438
21074
  has_reachable_contact: hasReachableContact,
20439
21075
  resolved_from: params._resolved?.from ?? null,
20440
21076
  resolved_query: params._resolved?.query ?? null,
20441
- match_candidates: params._resolved?.candidates ?? null
21077
+ match_candidates: params._resolved?.candidates ?? null,
21078
+ resolved_matched_on: params._resolved?.matched_on ?? null
20442
21079
  }
20443
21080
  }, _ctx);
20444
21081
  }
@@ -20460,16 +21097,8 @@ researchLeadById.execute = async (client, params, ctx) => {
20460
21097
  };
20461
21098
 
20462
21099
  // ../core/dist/composite/research-lead-by-name-fuzzy.js
20463
- function rankSubstringMatches(needle, candidates) {
20464
- const n = needle.toLowerCase();
20465
- const hits = candidates.filter((c) => typeof c.name === "string" && c.name.toLowerCase().includes(n));
20466
- hits.sort((a, b) => {
20467
- const aScore = a.score ?? -Infinity;
20468
- const bScore = b.score ?? -Infinity;
20469
- return bScore - aScore;
20470
- });
20471
- return hits;
20472
- }
21100
+ var RESOLVE_TIMEOUT_MS = 1e4;
21101
+ var MAX_AMBIGUOUS_CANDIDATES = 4;
20473
21102
  function parseLensId(value) {
20474
21103
  const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : Number.NaN;
20475
21104
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
@@ -20484,6 +21113,34 @@ function suggestionLeadId(suggestion) {
20484
21113
  function isLeadbayError(error) {
20485
21114
  return typeof error === "object" && error !== null && error.error === true && typeof error.code === "string" && typeof error.message === "string" && typeof error.hint === "string";
20486
21115
  }
21116
+ function businessDomainFromEmail(email) {
21117
+ if (!email || typeof email !== "string")
21118
+ return null;
21119
+ const at = email.lastIndexOf("@");
21120
+ if (at === -1)
21121
+ return null;
21122
+ const domain = normalizeDomain(email.slice(at + 1));
21123
+ if (!domain)
21124
+ return null;
21125
+ return PUBLIC_MAILBOX_DOMAINS.has(domain) ? null : domain;
21126
+ }
21127
+ function buildResolvePayload(params) {
21128
+ const queryDomain = normalizeDomain(params.query);
21129
+ const website = (params.website ? normalizeDomain(params.website) : null) ?? queryDomain ?? businessDomainFromEmail(params.email);
21130
+ const payload = {};
21131
+ if (!queryDomain)
21132
+ payload.name = params.query;
21133
+ if (website)
21134
+ payload.website = website;
21135
+ if (params.email)
21136
+ payload.email = params.email;
21137
+ if (params.registry_number)
21138
+ payload.registry_number = params.registry_number;
21139
+ return payload;
21140
+ }
21141
+ function hasStrongIdentityKey(payload) {
21142
+ return Boolean(payload.website || payload.registry_number);
21143
+ }
20487
21144
  async function resolveWithinLens(client, query, lensId) {
20488
21145
  const results = await client.request("GET", `/lenses/${lensId}/leads/wishlist?q=${encodeURIComponent(query)}&count=50&page=0&contacts=false`);
20489
21146
  return results.items.map((lead) => ({
@@ -20505,6 +21162,24 @@ async function resolveAcrossVisibleCorpus(client, query) {
20505
21162
  };
20506
21163
  }).filter((suggestion) => suggestion.id !== "" && suggestion.name !== "");
20507
21164
  }
21165
+ async function hydrateAmbiguous(client, candidates, lensId) {
21166
+ const selected = candidates.slice(0, MAX_AMBIGUOUS_CANDIDATES);
21167
+ const settled = await Promise.allSettled(selected.map((c) => client.request("GET", `/lenses/${lensId}/leads/${c.lead_id}`)));
21168
+ return selected.map((c, i) => {
21169
+ const r = settled[i];
21170
+ const lead = r.status === "fulfilled" ? r.value : null;
21171
+ return {
21172
+ leadId: c.lead_id,
21173
+ name: lead?.name ?? null,
21174
+ website: lead?.website ?? null,
21175
+ location: lead?.location?.full ?? lead?.location?.city ?? lead?.location?.country ?? null,
21176
+ registry_ids: lead?.registry_ids ?? null,
21177
+ score: c.score,
21178
+ matched_on: c.matched_on,
21179
+ lead_fields_populated: c.lead_fields_populated
21180
+ };
21181
+ });
21182
+ }
20508
21183
  var researchLeadByNameFuzzy = {
20509
21184
  name: "leadbay_research_lead_by_name_fuzzy",
20510
21185
  annotations: {
@@ -20520,11 +21195,23 @@ var researchLeadByNameFuzzy = {
20520
21195
  properties: {
20521
21196
  companyName: {
20522
21197
  type: "string",
20523
- description: "Company name, domain, or contact name to resolve across visible Leadbay leads in Discover, Monitor, and Activate."
21198
+ description: "Company name, domain, or contact name. Resolved against the user's own Discover/Monitor/Activate leads and the Leadbay company registry."
21199
+ },
21200
+ website: {
21201
+ type: "string",
21202
+ 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."
21203
+ },
21204
+ email: {
21205
+ type: "string",
21206
+ 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."
21207
+ },
21208
+ registry_number: {
21209
+ type: "string",
21210
+ 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."
20524
21211
  },
20525
21212
  lensId: {
20526
21213
  type: "number",
20527
- description: "Optional strict scope. When supplied, search only this lens's wishlist; normally omit to search all visible Leadbay leads."
21214
+ description: "Optional strict scope. When supplied, search only this lens's wishlist and do NOT fall through to the registry; normally omit."
20528
21215
  },
20529
21216
  concise: {
20530
21217
  type: "boolean",
@@ -20540,59 +21227,123 @@ var researchLeadByNameFuzzy = {
20540
21227
  additionalProperties: false
20541
21228
  },
20542
21229
  // Output shape matches leadbay_research_lead_by_id; the only additions are
20543
- // _meta.resolved_from / resolved_query / match_candidates which are
20544
- // documented on _by_id's output schema. Defer to _by_id for the schema —
20545
- // duplicating it would just rot.
21230
+ // _meta.resolved_from / resolved_query / resolved_matched_on /
21231
+ // match_candidates which are documented on _by_id's output schema. Defer to
21232
+ // _by_id for the schema — duplicating it would just rot. The one exception
21233
+ // is the ambiguous branch, which returns a disambiguation payload instead
21234
+ // of a research card.
20546
21235
  outputSchema: {
20547
21236
  type: "object",
20548
- 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.",
21237
+ 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.",
20549
21238
  additionalProperties: true
20550
21239
  },
20551
21240
  execute: async (client, params, ctx) => {
20552
21241
  if (!params.companyName || typeof params.companyName !== "string" || params.companyName.trim() === "") {
20553
- 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.");
21242
+ 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.");
20554
21243
  }
20555
21244
  const query = params.companyName.trim();
20556
- let ranked;
20557
21245
  let lensId = params.lensId;
20558
- let usedActiveLensFallback = false;
20559
- if (lensId !== void 0) {
20560
- ranked = await resolveWithinLens(client, query, lensId);
20561
- } else {
21246
+ if (params.lensId !== void 0) {
21247
+ const scoped = await resolveWithinLens(client, query, params.lensId);
21248
+ if (scoped.length > 0) {
21249
+ return await delegate(scoped, params.lensId);
21250
+ }
21251
+ 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.");
21252
+ }
21253
+ async function delegate(matches, fallbackLens) {
21254
+ const [primary, ...rest] = matches;
21255
+ const resolvedLens = primary.lensId ?? fallbackLens ?? await client.resolveDefaultLens();
21256
+ return await researchLeadById.execute(client, {
21257
+ leadId: primary.id,
21258
+ lensId: resolvedLens,
21259
+ concise: params.concise,
21260
+ response_format: params.response_format,
21261
+ _resolved: {
21262
+ from: "companyName",
21263
+ query,
21264
+ candidates: rest.slice(0, MAX_AMBIGUOUS_CANDIDATES).map((m) => ({
21265
+ leadId: m.id,
21266
+ name: m.name,
21267
+ score: m.score
21268
+ }))
21269
+ }
21270
+ }, ctx);
21271
+ }
21272
+ const payload = buildResolvePayload({
21273
+ query,
21274
+ website: params.website,
21275
+ email: params.email,
21276
+ registry_number: params.registry_number
21277
+ });
21278
+ let corpusSearched = false;
21279
+ let ranked = [];
21280
+ const searchCorpus = async (strict) => {
20562
21281
  try {
20563
21282
  ranked = await resolveAcrossVisibleCorpus(client, query);
21283
+ corpusSearched = true;
20564
21284
  } catch (error) {
20565
- if (isLeadbayError(error))
21285
+ if (strict && isLeadbayError(error))
20566
21286
  throw error;
20567
- lensId = await client.resolveDefaultLens();
20568
- usedActiveLensFallback = true;
20569
- ctx?.logger?.warn?.("Cross-tab company search was unavailable; falling back to the active lens for this lookup.");
20570
- ranked = rankSubstringMatches(query, await resolveWithinLens(client, query, lensId));
20571
- }
20572
- }
20573
- if (ranked.length === 0) {
20574
- 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}`;
20575
- 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.";
20576
- throw client.makeError("LEAD_NOT_FOUND", `No lead matching "${query}" ${scope}`, hint);
20577
- }
20578
- const [primary, ...rest] = ranked;
20579
- lensId = primary.lensId ?? lensId ?? await client.resolveDefaultLens();
20580
- const candidates = rest.slice(0, 4).map((m) => ({
20581
- leadId: m.id,
20582
- name: m.name,
20583
- score: m.score
20584
- }));
20585
- return await researchLeadById.execute(client, {
20586
- leadId: primary.id,
20587
- lensId,
20588
- concise: params.concise,
20589
- response_format: params.response_format,
20590
- _resolved: {
20591
- from: "companyName",
20592
- query,
20593
- candidates
21287
+ ctx?.logger?.warn?.("Cross-tab company search was unavailable; resolving against the Leadbay registry instead.");
20594
21288
  }
20595
- }, ctx);
21289
+ };
21290
+ const registryFirst = hasStrongIdentityKey(payload);
21291
+ if (!registryFirst) {
21292
+ await searchCorpus(true);
21293
+ if (ranked.length > 0)
21294
+ return await delegate(ranked);
21295
+ }
21296
+ let resolved;
21297
+ try {
21298
+ resolved = await client.request("POST", "/leads/resolve", payload, { timeoutMs: RESOLVE_TIMEOUT_MS });
21299
+ } catch (error) {
21300
+ if (isLeadbayError(error))
21301
+ throw error;
21302
+ 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");
21303
+ }
21304
+ if (resolved.type === "matched") {
21305
+ lensId = lensId ?? await client.resolveDefaultLens();
21306
+ return await researchLeadById.execute(client, {
21307
+ leadId: resolved.lead_id,
21308
+ lensId,
21309
+ concise: params.concise,
21310
+ response_format: params.response_format,
21311
+ _resolved: {
21312
+ from: "resolver",
21313
+ query,
21314
+ candidates: [],
21315
+ matched_on: resolved.matched_on
21316
+ }
21317
+ }, ctx);
21318
+ }
21319
+ if (resolved.type === "ambiguous" && resolved.candidates.length > 0) {
21320
+ const hydrationLens = lensId ?? await client.resolveDefaultLens();
21321
+ const candidates = await hydrateAmbiguous(client, resolved.candidates, hydrationLens);
21322
+ return {
21323
+ resolution: "ambiguous",
21324
+ query,
21325
+ resolver_payload: payload,
21326
+ candidates,
21327
+ 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.",
21328
+ _meta: {
21329
+ region: client.region,
21330
+ lens_id: hydrationLens,
21331
+ resolved_from: "resolver",
21332
+ resolved_query: query
21333
+ }
21334
+ };
21335
+ }
21336
+ if (!corpusSearched) {
21337
+ await searchCorpus(false);
21338
+ if (ranked.length > 0)
21339
+ return await delegate(ranked);
21340
+ }
21341
+ const registryScope = payload.website ? `the Leadbay company registry (domain ${payload.website})` : "the Leadbay company registry";
21342
+ 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`;
21343
+ const wanted = resolved.type === "none" && resolved.would_help.length > 0 ? resolved.would_help : ["website", "registry_number"];
21344
+ 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 ");
21345
+ 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.`;
21346
+ throw client.makeError("LEAD_NOT_FOUND", `No company matching "${query}" ${searched}`, hint, "POST /leads/resolve");
20596
21347
  }
20597
21348
  };
20598
21349
 
@@ -22643,6 +23394,19 @@ var importAndQualify = {
22643
23394
  type: "string",
22644
23395
  description: "Import handle to pass to leadbay_import_status when wait_for_completion=false."
22645
23396
  },
23397
+ timed_out: {
23398
+ type: "boolean",
23399
+ 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."
23400
+ },
23401
+ rows_pending_upload: {
23402
+ type: "number",
23403
+ description: "Rows from later chunks that never reached the backend. These are NOT running anywhere; re-import just those rows."
23404
+ },
23405
+ row_ids: {
23406
+ type: "array",
23407
+ 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.",
23408
+ items: { type: "string" }
23409
+ },
22646
23410
  // preview-shape keys
22647
23411
  mapping_hints: {
22648
23412
  type: "array",
@@ -22786,7 +23550,7 @@ var importAndQualify = {
22786
23550
  return {
22787
23551
  kind: "result",
22788
23552
  status: "running",
22789
- handle_id: queued.handle_id,
23553
+ ...queued.handle_id ? { handle_id: queued.handle_id } : {},
22790
23554
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
22791
23555
  qualify_id: null,
22792
23556
  import_ids: queued.importIds,
@@ -22818,7 +23582,35 @@ var importAndQualify = {
22818
23582
  wait_for_completion: true
22819
23583
  }, ctx);
22820
23584
  if (isImportLeadsRunningResult(importResultRaw)) {
22821
- 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");
23585
+ return {
23586
+ kind: "result",
23587
+ status: "running",
23588
+ ...importResultRaw.handle_id ? { handle_id: importResultRaw.handle_id } : {},
23589
+ // Everything the rendering contract keys off has to survive the
23590
+ // wrapper. Without `timed_out` the agent can't tell this from a
23591
+ // deliberate async launch; without `rows_pending_upload` a >100-row
23592
+ // batch silently loses every unuploaded chunk; without `dry_run`
23593
+ // leadbay_import_status can't tell a validation pass from a real
23594
+ // import still committing.
23595
+ ...importResultRaw.timed_out ? { timed_out: true } : {},
23596
+ ...importResultRaw.rows_pending_upload !== void 0 ? { rows_pending_upload: importResultRaw.rows_pending_upload } : {},
23597
+ ...importResultRaw.dry_run ? { dry_run: true } : {},
23598
+ ...importResultRaw.row_ids ? { row_ids: importResultRaw.row_ids } : {},
23599
+ ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
23600
+ qualify_id: null,
23601
+ import_ids: importResultRaw.importIds,
23602
+ notification_ids: importResultRaw.notification_ids ?? [],
23603
+ imported: [],
23604
+ not_imported: (importResultRaw.not_imported ?? []).map(toNotImportedEntry),
23605
+ qualified: [],
23606
+ still_running: [],
23607
+ failed: [],
23608
+ quota_exceeded: false,
23609
+ skipped_already_qualified: [],
23610
+ not_in_lens: [],
23611
+ region: client.region,
23612
+ _meta: importResultRaw._meta
23613
+ };
22822
23614
  }
22823
23615
  const importResult = importResultRaw;
22824
23616
  if (importResult.cancelled) {
@@ -23078,7 +23870,7 @@ async function runPreview(client, params, ctx, perPhaseBudget, _totalBudget) {
23078
23870
  throw Object.assign(new Error("aborted"), { name: "AbortError" });
23079
23871
  }
23080
23872
  if (!fileImport) {
23081
- 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}`);
23873
+ 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}`);
23082
23874
  }
23083
23875
  if (fileImport.pre_processing?.error) {
23084
23876
  throw client.makeError("IMPORT_PREPROCESS_FAILED", `Preview preprocess failed: ${fileImport.pre_processing.error}`, "Inspect the input rows for encoding / shape issues.", `GET /imports/${importId}`);
@@ -23161,6 +23953,72 @@ function summarizeImports(imports, dryRun) {
23161
23953
  records_total: recordsTotal
23162
23954
  };
23163
23955
  }
23956
+ var RECORDS_PAGE_SIZE = 100;
23957
+ var RECORDS_MAX_PAGES = 20;
23958
+ var ImportNotReady = class extends Error {
23959
+ };
23960
+ function isInProgress(err) {
23961
+ return /in_progress/i.test(String(err?.message ?? ""));
23962
+ }
23963
+ async function fetchReconciledRecords(client, importIds, declaredTotal, ctx) {
23964
+ const canonicalLeadIds = /* @__PURE__ */ new Set();
23965
+ for (const importId of importIds) {
23966
+ try {
23967
+ const res = await client.request("GET", `/imports/${importId}/leads`);
23968
+ for (const id of res?.lead_ids ?? [])
23969
+ canonicalLeadIds.add(id);
23970
+ } catch (err) {
23971
+ if (isInProgress(err))
23972
+ throw new ImportNotReady();
23973
+ if (err?.code !== "NOT_FOUND" && err?._meta?.http_status !== 404)
23974
+ throw err;
23975
+ ctx?.logger?.warn?.(`import-status: /imports/${importId}/leads not available on this backend (404) \u2014 using records only`);
23976
+ }
23977
+ }
23978
+ const all = [];
23979
+ for (const importId of importIds) {
23980
+ for (let page = 0; page < RECORDS_MAX_PAGES; page++) {
23981
+ const qs = `count=${RECORDS_PAGE_SIZE}&page=${page}&automatic_match=true&manual_match=true&no_match=true&matching=true&importing=true&imported=true`;
23982
+ let res;
23983
+ try {
23984
+ res = await client.request("GET", `/imports/${importId}/records?${qs}`);
23985
+ } catch (err) {
23986
+ if (isInProgress(err))
23987
+ throw new ImportNotReady();
23988
+ throw err;
23989
+ }
23990
+ all.push(...res.items);
23991
+ const totalPages = res.pagination.pages ?? 0;
23992
+ if (page + 1 >= totalPages)
23993
+ break;
23994
+ if (page + 1 === RECORDS_MAX_PAGES) {
23995
+ ctx?.logger?.warn?.(`import-status: importId=${importId} has >${RECORDS_MAX_PAGES} record pages; skipping reconciliation`);
23996
+ return null;
23997
+ }
23998
+ }
23999
+ }
24000
+ const { leads, not_imported, pending, distinct, pendingLeadIds } = reconcileRecords(all);
24001
+ const deficit = settlingDeficit(declaredTotal, distinct);
24002
+ const seenLeadIds = new Set(leads.map((l) => l.leadId));
24003
+ const merged = [...leads];
24004
+ if (deficit === 0) {
24005
+ for (const id of canonicalLeadIds) {
24006
+ if (seenLeadIds.has(id))
24007
+ continue;
24008
+ if (pendingLeadIds.has(id))
24009
+ continue;
24010
+ merged.push({ leadId: id, name: null });
24011
+ }
24012
+ }
24013
+ return {
24014
+ leads: merged,
24015
+ not_imported,
24016
+ // A snapshot short of the declared row count is not final — see
24017
+ // `settlingDeficit`. Measured on DISTINCT rows: `all.length` counts a
24018
+ // re-paged row twice and would mask a genuine shortfall.
24019
+ still_settling: pending + deficit
24020
+ };
24021
+ }
23164
24022
  var importStatus = {
23165
24023
  name: "leadbay_import_status",
23166
24024
  annotations: {
@@ -23180,8 +24038,12 @@ var importStatus = {
23180
24038
  },
23181
24039
  importIds: {
23182
24040
  type: "array",
23183
- description: "Legacy backend file-import ids to inspect directly.",
24041
+ description: "Backend file-import ids to inspect directly \u2014 from a completed import's `importIds`, or from a `{status:'running', timed_out:true}` result.",
23184
24042
  items: { type: "string" }
24043
+ },
24044
+ dry_run: {
24045
+ type: "boolean",
24046
+ 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."
23185
24047
  }
23186
24048
  },
23187
24049
  additionalProperties: false
@@ -23195,9 +24057,13 @@ var importStatus = {
23195
24057
  progress: { type: "object" },
23196
24058
  result: {
23197
24059
  type: "object",
23198
- description: "Final import result when the handle has completed in this MCP instance."
24060
+ 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."
23199
24061
  },
23200
24062
  error: { type: "string" },
24063
+ dry_run: {
24064
+ type: "boolean",
24065
+ 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."
24066
+ },
23201
24067
  region: { type: "string" },
23202
24068
  _meta: { type: "object" }
23203
24069
  },
@@ -23206,7 +24072,7 @@ var importStatus = {
23206
24072
  execute: async (client, params, ctx) => {
23207
24073
  let handleId = params.handle_id;
23208
24074
  let importIds = params.importIds ?? [];
23209
- let handleDryRun;
24075
+ let handleDryRun = params.dry_run;
23210
24076
  if (handleId) {
23211
24077
  if (!isValidBulkId(handleId)) {
23212
24078
  throw client.makeError("BULK_INVALID_ID", "handle_id is not a valid UUIDv4", "Pass the handle_id returned by leadbay_import_leads verbatim.", "");
@@ -23223,7 +24089,7 @@ var importStatus = {
23223
24089
  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.", "");
23224
24090
  }
23225
24091
  importIds = record.import_ids;
23226
- handleDryRun = record.dry_run;
24092
+ handleDryRun = record.dry_run ?? handleDryRun;
23227
24093
  if (record.status === "complete" && record.result) {
23228
24094
  return {
23229
24095
  status: "complete",
@@ -23284,6 +24150,7 @@ var importStatus = {
23284
24150
  };
23285
24151
  }
23286
24152
  }
24153
+ importIds = [...new Set(importIds)];
23287
24154
  if (importIds.length === 0) {
23288
24155
  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.", "");
23289
24156
  }
@@ -23299,14 +24166,40 @@ var importStatus = {
23299
24166
  return Boolean(i.processing?.finished);
23300
24167
  return Boolean(i.processing?.finished || i.pre_processing?.finished && !i.processing);
23301
24168
  });
24169
+ let reconciled = null;
24170
+ const commitError = commitFailureFor(importIds);
24171
+ let notReady = false;
24172
+ const declaredTotal = imports.reduce((n, i) => n + Number(i.total_records ?? 0), 0);
24173
+ if (!failed && complete && handleDryRun !== true && importIds.length > 0) {
24174
+ try {
24175
+ reconciled = await fetchReconciledRecords(client, importIds, declaredTotal, ctx);
24176
+ } catch (err) {
24177
+ if (err instanceof ImportNotReady) {
24178
+ notReady = true;
24179
+ ctx?.logger?.info?.(`import-status: wizard reports in_progress; mappings not committed yet \u2014 reporting running`);
24180
+ } else {
24181
+ ctx?.logger?.warn?.(`import-status: records reconciliation failed (${err?.code ?? err?.message ?? "unknown"}); returning status only`);
24182
+ }
24183
+ }
24184
+ }
24185
+ const settled = complete && !notReady;
23302
24186
  return {
23303
- status: failed ? "failed" : complete ? "complete" : "running",
24187
+ status: failed || commitError ? "failed" : settled ? "complete" : "running",
23304
24188
  ...handleId ? { handle_id: handleId } : {},
23305
24189
  importIds,
23306
- progress,
24190
+ ...handleDryRun === true ? { dry_run: true } : {},
24191
+ progress: notReady ? { ...progress, phase: "committing" } : progress,
24192
+ ...reconciled ? {
24193
+ result: {
24194
+ leads: reconciled.leads,
24195
+ not_imported: reconciled.not_imported,
24196
+ importIds,
24197
+ ...reconciled.still_settling > 0 ? { still_settling: reconciled.still_settling } : {}
24198
+ }
24199
+ } : {},
23307
24200
  ...failed ? {
23308
24201
  error: failed.pre_processing?.error ?? failed.processing?.error ?? "import failed"
23309
- } : {},
24202
+ } : commitError ? { error: `Import mappings were rejected: ${commitError}` } : {},
23310
24203
  region: client.region,
23311
24204
  _meta: client.lastMeta ?? {
23312
24205
  region: client.region,
@@ -26461,9 +27354,9 @@ var sendFeedback = {
26461
27354
  };
26462
27355
 
26463
27356
  // ../core/dist/artifact-runtime.generated.js
26464
- var ARTIFACT_KIT_VERSION = "0.3.1";
26465
- var 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);})();';
26466
- var 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.';
27357
+ var ARTIFACT_KIT_VERSION = "0.5.0";
27358
+ var 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);})();';
27359
+ var 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.';
26467
27360
 
26468
27361
  // ../core/dist/tools/artifact-kit.js
26469
27362
  var artifactKit = {
@@ -26698,6 +27591,13 @@ var compositeWriteTools = [
26698
27591
  // to the agent without requiring LEADBAY_MCP_ADVANCED=1.
26699
27592
  likeLead,
26700
27593
  dislikeLead,
27594
+ // Org-wide CRM status (WANTED/WON/LOST/UNWANTED). Granular-shaped but
27595
+ // registered HERE, not in granularWriteTools: reps state deal outcomes in
27596
+ // ordinary conversation, and the artifact-kit status dropdown calls it —
27597
+ // both need it on the default surface without LEADBAY_MCP_ADVANCED=1.
27598
+ // Distinct from setEpilogueStatus (outreach disposition), which stays
27599
+ // advanced-gated.
27600
+ setLeadStatus,
26701
27601
  // Campaign write composites — persist a hand-picked cohort of leads.
26702
27602
  // Backend POST endpoints; gated behind LEADBAY_MCP_WRITE=1 in MCP.
26703
27603
  createCampaign,
@@ -26832,6 +27732,7 @@ var EMBEDDED_SENTRY_DSN = "https://301f1c433433b76132956ed5415bea19@o45058744368
26832
27732
  // src/telemetry-events.ts
26833
27733
  var EV_TOOL_CALL = "mcp tool called";
26834
27734
  var EV_QUOTA_HIT = "mcp quota hit";
27735
+ var EV_TOOL_TIMEOUT = "mcp tool timeout";
26835
27736
  var EV_TOPUP_LINK = "mcp topup link created";
26836
27737
  var EV_STARTUP = "mcp startup";
26837
27738
  var EV_MCP_UPDATE_CHECK = "mcp update check";
@@ -26842,6 +27743,7 @@ var EV_MCP_VERSION_UPDATED = "mcp version updated";
26842
27743
  var EV_AGENT_MEMORY_CAPTURED = "agent_memory_captured";
26843
27744
  var EV_AGENT_MEMORY_RECALLED = "agent_memory_recalled";
26844
27745
  var EV_AGENT_MEMORY_PRUNED = "agent_memory_pruned";
27746
+ var DURATION_PLAUSIBILITY_CEILING_MS = 6e5;
26845
27747
  var EV_FRICTION_REPORTED = "mcp friction reported";
26846
27748
  var EV_COMPOSITE_CALL = "mcp composite call";
26847
27749
 
@@ -26855,6 +27757,8 @@ var NOOP_TELEMETRY = {
26855
27757
  },
26856
27758
  captureQuotaHit: (_props, _identity) => {
26857
27759
  },
27760
+ captureToolTimeout: (_props, _identity) => {
27761
+ },
26858
27762
  captureTopupLink: (_props, _identity) => {
26859
27763
  },
26860
27764
  captureStartup: (_props, _identity) => {
@@ -26889,6 +27793,13 @@ function parseTelemetryEnv(raw) {
26889
27793
  if (v === "false" || v === "0" || v === "no" || v === "off") return false;
26890
27794
  return true;
26891
27795
  }
27796
+ function withPlausibleDuration(props) {
27797
+ const { duration_ms, ...rest } = props;
27798
+ if (Number.isFinite(duration_ms) && duration_ms >= 0 && duration_ms <= DURATION_PLAUSIBILITY_CEILING_MS) {
27799
+ return { ...rest, duration_ms };
27800
+ }
27801
+ return { ...rest, duration_ms_raw: duration_ms, duration_implausible: true };
27802
+ }
26892
27803
  function initTelemetry(opts) {
26893
27804
  if (!parseTelemetryEnv(process.env.LEADBAY_TELEMETRY_ENABLED)) return NOOP_TELEMETRY;
26894
27805
  if (process.env.NODE_ENV === "test") return NOOP_TELEMETRY;
@@ -27057,14 +27968,17 @@ function initTelemetry(opts) {
27057
27968
  return identityPromise;
27058
27969
  },
27059
27970
  captureToolCall(props, identity) {
27060
- emit(EV_TOOL_CALL, { ...props }, identity);
27971
+ emit(EV_TOOL_CALL, withPlausibleDuration(props), identity);
27061
27972
  },
27062
27973
  captureCompositeCall(props, identity) {
27063
- emit(EV_COMPOSITE_CALL, { ...props }, identity);
27974
+ emit(EV_COMPOSITE_CALL, withPlausibleDuration(props), identity);
27064
27975
  },
27065
27976
  captureQuotaHit(props, identity) {
27066
27977
  emit(EV_QUOTA_HIT, { ...props }, identity);
27067
27978
  },
27979
+ captureToolTimeout(props, identity) {
27980
+ emit(EV_TOOL_TIMEOUT, { ...props }, identity);
27981
+ },
27068
27982
  captureTopupLink(props, identity) {
27069
27983
  emit(EV_TOPUP_LINK, { ...props }, identity);
27070
27984
  },
@@ -27503,6 +28417,7 @@ function buildAcknowledgeUpdateTool(opts) {
27503
28417
 
27504
28418
  // src/server-instructions.generated.ts
27505
28419
  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.`;
28420
+ 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.`;
27506
28421
  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.`;
27507
28422
  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.`;
27508
28423
  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.
@@ -27652,6 +28567,9 @@ function buildServerInstructions(exposed) {
27652
28567
  parts.push(TRIGGERED_BY);
27653
28568
  parts.push(MENTAL_MODEL);
27654
28569
  parts.push(QUOTA_TOPUP);
28570
+ if (has("leadbay_enrich_titles")) {
28571
+ parts.push(ENRICHMENT_TERMINAL);
28572
+ }
27655
28573
  parts.push(TRANSIENT_401);
27656
28574
  parts.push(buildScoringParagraph(has));
27657
28575
  parts.push(FIRST_RUN_ROUTING);
@@ -27948,6 +28866,16 @@ function buildServer(client, opts = {}) {
27948
28866
  source: "business"
27949
28867
  };
27950
28868
  };
28869
+ const captureTimeoutAlert = (toolName, envelope, triggeredBy) => {
28870
+ const ms = envelope._meta?.timeout_ms;
28871
+ telemetry2.captureToolTimeout({
28872
+ tool: toolName,
28873
+ ...typeof ms === "number" ? { timeout_ms: ms } : {},
28874
+ ...envelope._meta?.endpoint ? { endpoint: envelope._meta.endpoint } : {},
28875
+ ...envelope._meta?.region ? { region: envelope._meta.region } : {},
28876
+ ...triggeredBy !== void 0 ? { triggered_by: triggeredBy } : {}
28877
+ });
28878
+ };
27951
28879
  const captureAgentMemoryTelemetry = (toolName, result) => {
27952
28880
  if (!result || typeof result !== "object") return;
27953
28881
  const meta = result._meta ?? {};
@@ -28112,7 +29040,7 @@ ${url}
28112
29040
  isError: true
28113
29041
  };
28114
29042
  }
28115
- const result = await tool.execute(client, args, {
29043
+ const result = await runWithRequestSignal(extra.signal, () => tool.execute(client, args, {
28116
29044
  logger: opts.logger,
28117
29045
  bulkTracker: opts.bulkTracker,
28118
29046
  notificationsInbox: opts.notificationsInbox,
@@ -28145,7 +29073,7 @@ ${url}
28145
29073
  ...report.tool_called ? { tool_called: report.tool_called } : {},
28146
29074
  ...report.severity ? { severity: report.severity } : {}
28147
29075
  }) === true
28148
- });
29076
+ }));
28149
29077
  await maybeAttachUpdate(name, result);
28150
29078
  maybeAttachNotifications(result);
28151
29079
  if (result && typeof result === "object" && result.error === true) {
@@ -28161,6 +29089,9 @@ ${url}
28161
29089
  endpoint: result._meta?.endpoint
28162
29090
  });
28163
29091
  }
29092
+ if (envCode === "TIMEOUT") {
29093
+ captureTimeoutAlert(name, result, triggered_by);
29094
+ }
28164
29095
  telemetry2.captureToolCall({
28165
29096
  tool: name,
28166
29097
  ok: false,
@@ -28291,6 +29222,9 @@ ${url}
28291
29222
  endpoint: err._meta?.endpoint
28292
29223
  });
28293
29224
  }
29225
+ if (!skipAnalytics && err.code === "TIMEOUT") {
29226
+ captureTimeoutAlert(name, err, triggered_by);
29227
+ }
28294
29228
  const httpStatus2 = err._meta?.http_status;
28295
29229
  if (!skipAnalytics) {
28296
29230
  telemetry2.captureToolCall({
@@ -28539,7 +29473,7 @@ function parseWriteEnv(env = process.env) {
28539
29473
  }
28540
29474
 
28541
29475
  // src/http-server.ts
28542
- var VERSION = true ? "0.31.1" : "0.0.0-dev";
29476
+ var VERSION = true ? "0.32.1" : "0.0.0-dev";
28543
29477
  var PORT = Number(process.env.PORT ?? 8080);
28544
29478
  var HOST = process.env.HOST ?? "0.0.0.0";
28545
29479
  var logger = {