@leadbay/mcp 0.34.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/http-server.ts
4
- import { randomUUID as randomUUID3 } from "crypto";
4
+ import { randomUUID as randomUUID2 } from "crypto";
5
5
  import { realpathSync } from "fs";
6
6
  import { basename } from "path";
7
7
  import { fileURLToPath } from "url";
@@ -47,7 +47,7 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN
47
47
 
48
48
  # Resilience rules for Leadbay long-running tools
49
49
 
50
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
50
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
51
51
 
52
52
  ## Rule 1 \u2014 Pin the lens
53
53
 
@@ -55,7 +55,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
55
55
 
56
56
  ## Rule 2 \u2014 Prefer async for bulk operations
57
57
 
58
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
58
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
59
59
 
60
60
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
61
61
 
@@ -71,6 +71,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
71
71
 
72
72
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
73
73
 
74
+ ## A launched job cannot be stopped
75
+
76
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
77
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
78
+ running result, that work is queued on Leadbay and runs to completion, and the
79
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
80
+ launched nothing and is not covered here.
81
+
82
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
83
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
84
+ work stopped. What to do next depends on what you are holding:
85
+
86
+ - **A handle.** Poll the status tool with it, and do not launch the work that
87
+ handle covers a second time \u2014 that spends the quota again on the same rows.
88
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
89
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
90
+ notification of its own: resume it with
91
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
92
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
93
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
94
+ for what was launched and re-run for that subset only, never for the whole
95
+ batch.
96
+ - **No result at all**, because the call timed out or the stream closed before it
97
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
98
+ finished. Calling the same tool again with the same arguments will usually hand
99
+ back the job already launched rather than starting a second one, but that guard
100
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
101
+ are about to re-run before you spend the user's quota on it.
102
+
103
+
74
104
 
75
105
  # PHASE 0 \u2014 STATE + AUDIENCE
76
106
 
@@ -239,7 +269,7 @@ Run the Leadbay daily check-in for me. Treat this prompt the same way for any eq
239
269
 
240
270
  # Resilience rules for Leadbay long-running tools
241
271
 
242
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
272
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
243
273
 
244
274
  ## Rule 1 \u2014 Pin the lens
245
275
 
@@ -247,7 +277,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
247
277
 
248
278
  ## Rule 2 \u2014 Prefer async for bulk operations
249
279
 
250
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
280
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
251
281
 
252
282
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
253
283
 
@@ -263,6 +293,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
263
293
 
264
294
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
265
295
 
296
+ ## A launched job cannot be stopped
297
+
298
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
299
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
300
+ running result, that work is queued on Leadbay and runs to completion, and the
301
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
302
+ launched nothing and is not covered here.
303
+
304
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
305
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
306
+ work stopped. What to do next depends on what you are holding:
307
+
308
+ - **A handle.** Poll the status tool with it, and do not launch the work that
309
+ handle covers a second time \u2014 that spends the quota again on the same rows.
310
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
311
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
312
+ notification of its own: resume it with
313
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
314
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
315
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
316
+ for what was launched and re-run for that subset only, never for the whole
317
+ batch.
318
+ - **No result at all**, because the call timed out or the stream closed before it
319
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
320
+ finished. Calling the same tool again with the same arguments will usually hand
321
+ back the job already launched rather than starting a second one, but that guard
322
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
323
+ are about to re-run before you spend the user's quota on it.
324
+
325
+
266
326
 
267
327
  # PHASE 0 \u2014 RESUME CHECK
268
328
 
@@ -357,7 +417,7 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
357
417
 
358
418
  ABOVE the table, add a 2\u20134 sentence "Today's nudges" paragraph for the 3 most-promising rows. The nudges speak to urgency / opportunity / freshness \u2014 what makes acting on these RIGHT NOW the right call. Do NOT repeat the "why it fits" column from the table; the nudges should add fresh framing the table doesn't carry (e.g., recent news from the \`qualification_summary\` excerpt, a window closing, a competitor activity the user mentioned earlier in the session). One sentence per nudge, salesperson voice, not coachspeak.
359
419
 
360
- If the batch returns fewer than 10 qualified leads, top it up: call \`leadbay_bulk_qualify_leads\` with \`lensId:<captured>\`, \`count:<1.5x deficit, capped at 25>\`, and **\`wait_for_completion:false\`**. Capture \`qualify_id\` from the response and poll \`leadbay_qualify_status\` every ~10s until \`status:'done'\`. Then re-pull with the same \`lensId\` to pick up the newly qualified leads. **Never re-pull without \`lensId\` \u2014 you will lose your batch to a lens shift.** (The \`leadbay_qualify_top_n\` slash-prompt wraps this same tool with a friendlier surface for users; agents should call the underlying tool directly here.)
420
+ If the batch returns fewer than 10 qualified leads, top it up: call \`leadbay_bulk_qualify_leads\` with \`lensId:<captured>\`, \`count:<1.5x deficit, capped at 25>\`, and **\`wait_for_completion:false\`**. Capture \`notification_id\` from the response and poll \`leadbay_qualify_status\` every ~10s until \`status:'done'\`. Then re-pull with the same \`lensId\` to pick up the newly qualified leads. **Never re-pull without \`lensId\` \u2014 you will lose your batch to a lens shift.** (The \`leadbay_qualify_top_n\` slash-prompt wraps this same tool with a friendlier surface for users; agents should call the underlying tool directly here.)
361
421
 
362
422
  # PHASE 4 \u2014 DEEP DIVE (every promising lead)
363
423
 
@@ -471,7 +531,7 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN
471
531
 
472
532
  # Resilience rules for Leadbay long-running tools
473
533
 
474
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
534
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
475
535
 
476
536
  ## Rule 1 \u2014 Pin the lens
477
537
 
@@ -479,7 +539,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
479
539
 
480
540
  ## Rule 2 \u2014 Prefer async for bulk operations
481
541
 
482
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
542
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
483
543
 
484
544
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
485
545
 
@@ -495,6 +555,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
495
555
 
496
556
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
497
557
 
558
+ ## A launched job cannot be stopped
559
+
560
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
561
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
562
+ running result, that work is queued on Leadbay and runs to completion, and the
563
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
564
+ launched nothing and is not covered here.
565
+
566
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
567
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
568
+ work stopped. What to do next depends on what you are holding:
569
+
570
+ - **A handle.** Poll the status tool with it, and do not launch the work that
571
+ handle covers a second time \u2014 that spends the quota again on the same rows.
572
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
573
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
574
+ notification of its own: resume it with
575
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
576
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
577
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
578
+ for what was launched and re-run for that subset only, never for the whole
579
+ batch.
580
+ - **No result at all**, because the call timed out or the stream closed before it
581
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
582
+ finished. Calling the same tool again with the same arguments will usually hand
583
+ back the job already launched rather than starting a second one, but that guard
584
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
585
+ are about to re-run before you spend the user's quota on it.
586
+
587
+
498
588
 
499
589
  # THE ONE-FORWARD-OPTION RULE \u2014 the structural contract of this walkthrough
500
590
 
@@ -930,7 +1020,7 @@ account's **default wishlist selection** while \`confirm\`/\`email\` are set \u2
930
1020
  it would reveal and charge for the whole batch instead of the one lead the user
931
1021
  agreed to.
932
1022
 
933
- It returns a \`bulk_id\` and runs async \u2014 poll \`leadbay_bulk_enrich_status\`
1023
+ It returns a \`notification_id\` and runs async \u2014 poll \`leadbay_bulk_enrich_status\`
934
1024
  with that id (\`include_contacts=true\`) until \`all_done\`, or until the resolved
935
1025
  count plateaus across a few spaced polls. Then report the contact that actually
936
1026
  resolved: name, title, and the email/phone that came back. Contacts sometimes
@@ -1168,7 +1258,7 @@ Build the final mappings yourself. Start from \`leadbay_resolve_import_rows.mapp
1168
1258
 
1169
1259
  # PHASE 5 \u2014 QUALIFY (optional) + REPORT
1170
1260
 
1171
- 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).
1261
+ 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).
1172
1262
 
1173
1263
  **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.
1174
1264
 
@@ -1340,7 +1430,7 @@ You are working with Leadbay through the \`leadbay_*\` MCP tools. This prompt or
1340
1430
 
1341
1431
  # Resilience rules for Leadbay long-running tools
1342
1432
 
1343
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
1433
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
1344
1434
 
1345
1435
  ## Rule 1 \u2014 Pin the lens
1346
1436
 
@@ -1348,7 +1438,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
1348
1438
 
1349
1439
  ## Rule 2 \u2014 Prefer async for bulk operations
1350
1440
 
1351
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
1441
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
1352
1442
 
1353
1443
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
1354
1444
 
@@ -1364,6 +1454,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
1364
1454
 
1365
1455
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
1366
1456
 
1457
+ ## A launched job cannot be stopped
1458
+
1459
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
1460
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
1461
+ running result, that work is queued on Leadbay and runs to completion, and the
1462
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
1463
+ launched nothing and is not covered here.
1464
+
1465
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
1466
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
1467
+ work stopped. What to do next depends on what you are holding:
1468
+
1469
+ - **A handle.** Poll the status tool with it, and do not launch the work that
1470
+ handle covers a second time \u2014 that spends the quota again on the same rows.
1471
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
1472
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
1473
+ notification of its own: resume it with
1474
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
1475
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
1476
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
1477
+ for what was launched and re-run for that subset only, never for the whole
1478
+ batch.
1479
+ - **No result at all**, because the call timed out or the stream closed before it
1480
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
1481
+ finished. Calling the same tool again with the same arguments will usually hand
1482
+ back the job already launched rather than starting a second one, but that guard
1483
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
1484
+ are about to re-run before you spend the user's quota on it.
1485
+
1486
+
1367
1487
 
1368
1488
  ## The two entry points
1369
1489
 
@@ -1524,7 +1644,7 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN
1524
1644
  # PHASE 1 \u2014 LAUNCH
1525
1645
  Call \`leadbay_bulk_qualify_leads\` with \`count={{arg:count_or_default}}\` and \`wait_for_completion=true\` (synchronous mode \u2014 waits for results before returning).
1526
1646
 
1527
- **Resilience rule:** If \`leadbay_bulk_qualify_leads\` returns a BulkTracker-not-configured error or similar infrastructure error, do NOT retry with \`wait_for_completion=false\`. Instead, proceed directly to Phase 3 and call \`leadbay_pull_leads\` to surface the already-qualified leads in the current batch.
1647
+ **Resilience rule:** If \`leadbay_bulk_qualify_leads\` returns an infrastructure error, do NOT retry with \`wait_for_completion=false\`. Instead, proceed directly to Phase 3 and call \`leadbay_pull_leads\` to surface the already-qualified leads in the current batch.
1528
1648
 
1529
1649
  # PHASE 2 \u2014 POLL
1530
1650
  While it polls, expect notifications / progress events showing per-lead transitions. Surface meaningful ones (e.g. "lead X just finished") to me as they arrive \u2014 one inline status sentence per check, never expanded into a card:
@@ -2023,7 +2143,7 @@ If the prompt's body and the tool's RENDERING appear to conflict, the tool's REN
2023
2143
 
2024
2144
  # Resilience rules for Leadbay long-running tools
2025
2145
 
2026
- These four rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
2146
+ These rules apply to every Leadbay workflow that calls \`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_research_lead_by_id\`, \`leadbay_import_and_qualify\`, or \`leadbay_enrich_titles\`. **Treat timeouts and stream-closed errors as transient, not as signals to replan.**
2027
2147
 
2028
2148
  ## Rule 1 \u2014 Pin the lens
2029
2149
 
@@ -2031,7 +2151,7 @@ After your first \`leadbay_pull_leads\` call, capture \`response.lens.id\` into
2031
2151
 
2032
2152
  ## Rule 2 \u2014 Prefer async for bulk operations
2033
2153
 
2034
- \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\`, which returns \`{status:'running', qualify_id}\` immediately. Then poll \`leadbay_qualify_status\` (or \`leadbay_import_status\`) every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
2154
+ \`leadbay_bulk_qualify_leads\` and \`leadbay_import_and_qualify\` accept \`wait_for_completion:false\` and return immediately. They hand back different ids: \`bulk_qualify_leads\` returns \`{status:'running', notification_id, lead_ids, lens_id}\` \u2014 poll \`leadbay_qualify_status\` with those. \`import_and_qualify\` returns \`{status:'running', import_ids}\` and no \`notification_id\` at all \u2014 poll \`leadbay_import_status({importIds, dry_run})\` with those. Poll every ~10s until the job completes. **Use the async pattern by default** \u2014 the blocking default can exceed the MCP client's per-call timeout on large batches and produce a misleading \`"Request timed out"\` even though the server is still working.
2035
2155
 
2036
2156
  ## Rule 3 \u2014 Serialize \`leadbay_research_lead_by_id\` fan-out
2037
2157
 
@@ -2047,6 +2167,36 @@ If a Leadbay tool returns \`"Request timed out"\`, \`"stream closed"\`, or any o
2047
2167
 
2048
2168
  If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retry it, explicitly pass the lensId you captured (if any), and continue.
2049
2169
 
2170
+ ## A launched job cannot be stopped
2171
+
2172
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
2173
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
2174
+ running result, that work is queued on Leadbay and runs to completion, and the
2175
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
2176
+ launched nothing and is not covered here.
2177
+
2178
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
2179
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
2180
+ work stopped. What to do next depends on what you are holding:
2181
+
2182
+ - **A handle.** Poll the status tool with it, and do not launch the work that
2183
+ handle covers a second time \u2014 that spends the quota again on the same rows.
2184
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
2185
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
2186
+ notification of its own: resume it with
2187
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
2188
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
2189
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
2190
+ for what was launched and re-run for that subset only, never for the whole
2191
+ batch.
2192
+ - **No result at all**, because the call timed out or the stream closed before it
2193
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
2194
+ finished. Calling the same tool again with the same arguments will usually hand
2195
+ back the job already launched rather than starting a second one, but that guard
2196
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
2197
+ are about to re-run before you spend the user's quota on it.
2198
+
2199
+
2050
2200
 
2051
2201
  # PHASE 0 \u2014 SCOPE + STATE
2052
2202
 
@@ -2144,7 +2294,7 @@ So: **read the persisted filter first** (the response reports \`active_filters\`
2144
2294
 
2145
2295
  \u26A0 **Pass explicit \`leadIds\` whenever the cohort isn't simply "the next N on the lens"** \u2014 e.g. after you've selected a shortlist, or when the plan mixes Monitor and Discover rows. The \`count\`-based path selects the next *unqualified leads from the lens wishlist*, so on any other cohort it qualifies unrelated leads and hands you handles whose pills belong to different companies. Use \`leadbay_bulk_qualify_leads({leadIds:[\u2026\u226425 of the cohort], wait_for_completion:false})\` and chunk through the cohort's own ids. The \`{lensId, count}\` form is only right when the cohort genuinely *is* the lens's top N.
2146
2296
 
2147
- **Qualify the plan cohort, not the whole base.** Select your ~{{arg:count_or_default}} candidates (plus a modest buffer for drop-outs) BEFORE qualifying \u2014 qualification is async and quota-bearing, so running it across an entire portfolio to produce a top-{{arg:count_or_default}} burns the user's quota for rows that will never appear. **Keep every returned \`qualify_id\`** \u2014 the deck's live qualification layer is wired from those handles, and a deck with none is a dead deck that still looks finished. Never ship a plan whose lower ranks have empty qualification pills because only the first 25 were ever qualified.
2297
+ **Qualify the plan cohort, not the whole base.** Select your ~{{arg:count_or_default}} candidates (plus a modest buffer for drop-outs) BEFORE qualifying \u2014 qualification is async and quota-bearing, so running it across an entire portfolio to produce a top-{{arg:count_or_default}} burns the user's quota for rows that will never appear. **Keep every returned \`notification_id\`** \u2014 the deck's live qualification layer is wired from those handles, and a deck with none is a dead deck that still looks finished. Never ship a plan whose lower ranks have empty qualification pills because only the first 25 were ever qualified.
2148
2298
 
2149
2299
  **Signals \u2014 scoped to the cohort.** \u26A0 **Always pass the selected \`leadIds\`.** With \`leadIds\` omitted, \`leadbay_scan_portfolio_signals\` builds its own portfolio by paging \`/monitor\` \u2014 so on an imported cohort or a freshly-pulled Discover set it would scan a *different population* and you'd render dashes for accounts whose signals were never read.
2150
2300
 
@@ -2271,7 +2421,7 @@ Each card needs a reachable decision-maker. \`leadbay_enrich_titles({leadIds, le
2271
2421
 
2272
2422
  \u26A0 **Do NOT quote a cost or a credits figure.** The per-reveal rate is backend-side and enrichment is gated by quota, not a credit balance; \`credits_remaining\` is advisory context only. A spend number invented to make the offer concrete is the same failure as an invented euro on a card.
2273
2423
 
2274
- On an explicit yes, launch with the agreed \`titles\` + channels, then poll \`leadbay_bulk_enrich_status\` until done and **keep the \`bulk_id\` handles** for the deck.
2424
+ On an explicit yes, launch with the agreed \`titles\` + channels, then poll \`leadbay_bulk_enrich_status\` until done and **keep the \`notification_id\` handles** for the deck.
2275
2425
 
2276
2426
  \u26A0 **Render only the channels that actually came back.** The default reveal is email-only unless phone was explicitly requested, so never emit a \`tel:\` link for a contact whose phone was never revealed \u2014 show the channels enrichment returned and mark the rest omitted. A fabricated phone link is the same failure as a fabricated euro.
2277
2427
 
@@ -2417,9 +2567,9 @@ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We
2417
2567
  - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
2418
2568
 
2419
2569
 
2420
- \u26A0 **The deck's contact layer depends on what actually happened in Phase 5.** Bind a \`leadbay_bulk_enrich_status\` resource ONLY if a paid reveal was launched and you hold a \`bulk_id\`. If the user accepted the deck but not the reveal, render the contacts already on record and carry the paid-reveal offer inside the deck \u2014 never wire a status resource with no handle (it renders permanently empty) and never launch enrichment from the deck to manufacture one.
2570
+ \u26A0 **The deck's contact layer depends on what actually happened in Phase 5.** Bind a \`leadbay_bulk_enrich_status\` resource ONLY if a paid reveal was launched and you hold a \`notification_id\`. If the user accepted the deck but not the reveal, render the contacts already on record and carry the paid-reveal offer inside the deck \u2014 never wire a status resource with no handle (it renders permanently empty) and never launch enrichment from the deck to manufacture one.
2421
2571
 
2422
- On acceptance, call \`leadbay_artifact_kit\`, read its \`usage_guide\` before writing any code, and build a single-file deck. Wire the live layer from the handles you kept: a poll-until-done resource per \`qualify_id\` for the qualification pills, and one over \`leadbay_bulk_enrich_status\` for the contacts. \u26A0 **If enrichment already ran this session, bind the existing \`bulk_id\` \u2014 re-launching enrichment from the deck double-spends my quota.** Per-card notes and outcomes go through the pre-wired note/outreach view-models (they carry the required verification and \`_triggered_by\` fields; hand-rolling those is where it breaks). Keep the checklists in local storage, and always wire a Refresh \u2014 auto-poll is host-dependent. List every tool the deck calls in its \`mcp_tools\`, and render the bridge-unavailable branch, or the pills silently show empty.
2572
+ On acceptance, call \`leadbay_artifact_kit\`, read its \`usage_guide\` before writing any code, and build a single-file deck. Wire the live layer from the handles you kept: a poll-until-done resource per \`notification_id\` for the qualification pills, and one over \`leadbay_bulk_enrich_status\` for the contacts. \u26A0 **If enrichment already ran this session, bind the existing \`notification_id\` \u2014 re-launching enrichment from the deck double-spends my quota.** Per-card notes and outcomes go through the pre-wired note/outreach view-models (they carry the required verification and \`_triggered_by\` fields; hand-rolling those is where it breaks). Keep the checklists in local storage, and always wire a Refresh \u2014 auto-poll is host-dependent. List every tool the deck calls in its \`mcp_tools\`, and render the bridge-unavailable branch, or the pills silently show empty.
2423
2573
 
2424
2574
  # Iron laws
2425
2575
 
@@ -4104,7 +4254,7 @@ Some Leadbay tool responses include a \`_meta.notifications\` array listing **ba
4104
4254
  - \`leadbay_qualify_status\` \u2192 \`still_running\` is empty: every launched lead has finished or failed. (\`in_progress\` also reads \`false\` on the fast path, but it can be \`null\` on the legacy/fallback read \u2014 so treat an empty \`still_running\` as terminal on its own; only require \`in_progress:false\` when that field is actually present.) LIKE imports, large qualification runs are async by design: \`leadbay_bulk_qualify_leads\` defaults to \`wait_for_completion:false\` for \`count > 5\` or chained workflows because blocking can time out, and \`leadbay_qualify_status\` may take minutes/hours. So don't force a long polling loop on a big run \u2014 return the handle/progress and let completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked to wait, or it's a small run that finishes quickly. A small \`wait_for_completion:true\` run you can poll to \`still_running\` empty inline.
4105
4255
  - \`leadbay_import_status\` \u2192 \`status:"complete"\` (or \`"failed"\`). BUT imports are the exception to the stay-active loop: a large \`leadbay_import_leads({wait_for_completion:false})\` is meant to return a handle and resolve over minutes, and the tool does ONE refresh pass per call. Don't block the conversation looping on it \u2014 surface the returned progress/handle and let the completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked you to wait for the import, or it's a small import that finishes quickly.
4106
4256
 
4107
- Enrichment polls to completion in-turn BY DEFAULT \u2014 the exception is when the user explicitly said to start it in the background / not wait ("kick it off, I'll check later"), in which case hand back the bulk_id and let completion arrive via \`_meta.notifications\` (only when a notification id exists; if none was returned, tell the user to ask again / that you'll poll later, since nothing will auto-surface). For qualification and imports, poll inline only for small/quick runs or when the user explicitly asked you to wait; otherwise return the handle and let \`_meta.notifications\` deliver it. Either way, the user should never have to ask "is it done yet?" for work you kicked off in the same turn \u2014 you either report it or hand back a clear in-progress handle.
4257
+ Enrichment polls to completion in-turn BY DEFAULT \u2014 the exception is when the user explicitly said to start it in the background / not wait ("kick it off, I'll check later"), in which case hand back the notification_id and let completion arrive via \`_meta.notifications\` (only when a notification id exists; if none was returned, tell the user to ask again / that you'll poll later, since nothing will auto-surface). For qualification and imports, poll inline only for small/quick runs or when the user explicitly asked you to wait; otherwise return the handle and let \`_meta.notifications\` deliver it. Either way, the user should never have to ask "is it done yet?" for work you kicked off in the same turn \u2014 you either report it or hand back a clear in-progress handle.
4108
4258
 
4109
4259
  Also surfaced as a top-level \`notifications\` array on \`leadbay_account_status\` \u2014 same shape, same handling.
4110
4260
 
@@ -4411,23 +4561,57 @@ WHEN TO USE: the user asks for a clickable / interactive artifact, dashboard, or
4411
4561
 
4412
4562
  WHEN NOT TO USE: the user wants a plain data answer (route to leadbay_pull_leads / leadbay_pull_followups) or to log a single real outreach you just did (leadbay_report_outreach).
4413
4563
  `;
4414
- var leadbay_bulk_enrich_status = `Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Returns the \`bulk_id\`, progress per lead (done/total enrichable contacts), and overall progress. When \`include_contacts=true\` (opt-in), includes each contact's email/phone_number/job_title/enrichment.done.
4564
+ var leadbay_bulk_enrich_status = `Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Pass the \`notification_id\` for the job counters in one call, and/or the \`lead_ids\` + \`titles\` + \`email\` / \`phone\` the launch returned for per-lead progress. \`lead_ids\` alone is a valid call and is the reliable one: the job lookup is a scan of your recent notifications, so an archived job may not be found, and an enrichment notification does not always carry counters (then the tool answers \`ENRICH_JOB_NO_COUNTERS\` with the backend's running/finished flag and asks for \`lead_ids\`) \u2014 but the leads always answer. When \`include_contacts=true\` (opt-in), includes each contact's email/phone_number/job_title/enrichment.done.
4415
4565
 
4416
- WHEN TO USE: poll this REPEATEDLY after leadbay_enrich_titles returns a \`bulk_id\`, staying active until the job is done \u2014 don't stop after one check, and don't hand the turn back to the user while progress is still climbing. "Done" = \`all_done:true\`, OR \`overall_progress.done\` has held steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (some contacts are unresolvable and never flip, so \`all_done\` can stay false forever \u2014 don't spin indefinitely). Do NOT declare a plateau from the first few back-to-back reads: right after launch, \`overall_progress.done\` can sit flat while the backend is still spinning the job up, so space your polls out and give it real elapsed time before treating a flat count as terminal. Also do NOT declare a plateau while the result carries \`partial_failures\` \u2014 a flat \`done\` there means a transient per-lead fetch error (e.g. a 429), NOT an unresolvable contact; keep polling (respecting any \`retry_after\`) or surface it as a temporary status failure, rather than reporting those leads as permanently unresolved. Default \`include_contacts=false\` for the cheap interim polls; set \`include_contacts=true\` on the read you report from to pull each lead's enriched contacts for the completion report.
4566
+ WHEN TO USE: poll this REPEATEDLY after leadbay_enrich_titles returns a \`notification_id\`, staying active until the job is done \u2014 don't stop after one check, and don't hand the turn back to the user while progress is still climbing. "Done" = \`all_done:true\`, OR \`overall_progress.done\` has held steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (some contacts are unresolvable and never flip, so \`all_done\` can stay false forever \u2014 don't spin indefinitely). Do NOT declare a plateau from the first few back-to-back reads: right after launch, \`overall_progress.done\` can sit flat while the backend is still spinning the job up, so space your polls out and give it real elapsed time before treating a flat count as terminal. Also do NOT declare a plateau while the result carries \`partial_failures\` \u2014 a flat \`done\` there means a transient per-lead fetch error (e.g. a 429), NOT an unresolvable contact; keep polling (respecting any \`retry_after\`) or surface it as a temporary status failure, rather than reporting those leads as permanently unresolved. Default \`include_contacts=false\` for the cheap interim polls; set \`include_contacts=true\` on the read you report from to pull each lead's enriched contacts for the completion report.
4417
4567
 
4418
4568
  WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id \u2014 that already includes enriched contacts for a single lead.
4419
4569
 
4570
+ ## A launched job cannot be stopped
4571
+
4572
+ Leadbay has no cancel. A job started by \`leadbay_enrich_titles\`,
4573
+ \`leadbay_bulk_qualify_leads\`, \`leadbay_import_leads\` or
4574
+ \`leadbay_import_and_qualify\` runs to completion on Leadbay. The user cancelling
4575
+ in the chat, a request timeout, or a closed stream stops YOUR waiting, never the
4576
+ job, and \`cancelled: true\` on an earlier result means we stopped watching, not
4577
+ that the work stopped.
4578
+
4579
+ **This tool only reads.** Calling it again launches nothing and spends no quota,
4580
+ so poll it as often as the job needs \u2014 a timeout here is a reason to call it
4581
+ again, not a reason to stop.
4582
+
4583
+ One import state does NOT progress: a chunk cancelled before its mappings were
4584
+ committed reads \`running\` / \`committing\` forever. If the counts hold flat across
4585
+ several spaced polls, say so and stop, rather than polling on.
4586
+
4587
+ What must not be repeated is the LAUNCH \u2014 for work that actually launched. Re-run
4588
+ a launcher only for a subset that never started, never for the whole batch:
4589
+
4590
+ - \`failed[]\` entries with \`error:"not_queued"\`;
4591
+ - a \`rows_pending_upload\` count;
4592
+ - leads in \`still_running\` after a CANCELLED \`leadbay_import_and_qualify\`. Its
4593
+ fan-out is sequential, so an interruption leaves the remainder unlaunched and
4594
+ folds them in with the ones that did launch. Nothing in the result tells the
4595
+ two apart, and this tool cannot start either. Wait until the REST of the batch
4596
+ has settled: what launched settles in order, so leads still unanswered after
4597
+ that are the ones that never started. Only then call
4598
+ \`leadbay_bulk_qualify_leads({leadIds, lensId})\` for exactly those ids. A lead
4599
+ that is merely slow looks identical to one that never launched over a few
4600
+ polls, and re-launching it charges the user twice \u2014 when unsure, tell the user
4601
+ rather than guess.
4602
+
4603
+
4420
4604
  ## QUOTA \u2014 show where the user stands after the spend
4421
4605
 
4422
4606
  Enrichment consumes QUOTA (the per-window allowance), not a separate credit wall. Once the job is done (all_done, or a plateau \u2014 see WHEN TO USE), show the user their refreshed quota: call \`leadbay_account_status\` and render the per-window quota it returns (the canonical surface). The result's \`credits_remaining\` field is **advisory internal context only \u2014 do NOT display it**: it comes from \`billing.ai_credits\` (a consumed counter, not remaining), so printing \`_(N credits remaining)_\` can show a fresh/quota-backed account a false "0 remaining." Never render a credits balance; the \`leadbay_account_status\` quota gauge is the only place the user's standing is shown. Do NOT report a "credits used" figure for this run either: the per-contact cost can't be scoped to this specific enrichment (a lead's contact list mixes in earlier runs), so any "X used" number would be misleading. Do the account_status refresh ONCE at completion \u2014 not on every in-progress poll.
4423
4607
 
4424
4608
  ## COMPLETION REPORT \u2014 what to tell the user when the job is done
4425
4609
 
4426
- The result always carries \`overall_progress:{done,total,done_ratio}\` and, with \`include_contacts:true\`, \`leads[]\` each with contacts' \`email\` / \`phone_number\` / \`job_title\` / \`enrichment.done\`. When the read came back on the notification fast path it ALSO carries \`bulk_progress:{total_count,success_count,failure_count,quota_hit_count}\` \u2014 but the legacy per-lead fallback (older records with no \`notification_id\`, or a notification not yet visible) returns NO \`bulk_progress\`, so derive counts from \`overall_progress\` in that case rather than assuming \`bulk_progress\` is present. A contact counts as done only when the REQUESTED channel actually landed \u2014 for a phone run, \`enrichment.done:true\` with no \`phone_number\` is NOT done (the contact may have been email-enriched earlier); read \`email\` / \`phone_number\` against the requested channels, don't rely on the \`enrichment.done\` flag alone (\`overall_progress\` already accounts for this). \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), so it can include contacts of other roles that were enriched in earlier runs \u2014 filter your report to the \`titles\` this bulk enriched (match each contact's \`job_title\`), don't attribute a pre-existing email of an unrelated role to this run. Report it yourself in the SAME turn, without a reprompt and without deferring to a scheduled re-check: name which of the just-enriched contacts now have emails / phones, the done/total counts, and \u2014 if \`bulk_progress\` is present \u2014 any \`quota_hit_count\` (if non-zero, say some contacts were skipped because the quota window was exhausted, and point to \`leadbay_account_status\` for the wait-or-top-up choice). If you stopped on a plateau (not \`all_done\`), say so plainly \u2014 report the resolved contacts and name the ones that didn't resolve, keyed to the requested channel and the returned fields (no \`email\` \u2192 "no email found"; no \`phone_number\` \u2192 "no phone number found") \u2014 rather than implying the job fully finished. Then show refreshed quota via \`leadbay_account_status\` (see QUOTA above); do NOT print a credits-remaining line.
4610
+ The result always carries \`overall_progress:{done,total,done_ratio}\` and, with \`include_contacts:true\`, \`leads[]\` each with contacts' \`email\` / \`phone_number\` / \`job_title\` / \`enrichment.done\`. \`bulk_progress:{total_count,success_count,failure_count,quota_hit_count}\` is present only when you passed a \`notification_id\` AND the job was found; derive counts from \`overall_progress\` rather than assuming \`bulk_progress\` is there. With \`lead_ids\`, each entry carries \`enrichment_progress:{done,total}\` \u2014 \`done\` counts only contacts whose REQUESTED channel has landed, scoped to the \`titles\` this run enriched, so a lead's pre-existing CFO email cannot inflate a CEO run. A contact counts as done only when the REQUESTED channel actually landed \u2014 for a phone run, \`enrichment.done:true\` with no \`phone_number\` is NOT done (the contact may have been email-enriched earlier); read \`email\` / \`phone_number\` against the requested channels, don't rely on the \`enrichment.done\` flag alone (\`overall_progress\` already accounts for this). \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), so it can include contacts of other roles that were enriched in earlier runs \u2014 filter your report to the \`titles\` this bulk enriched (match each contact's \`job_title\`), don't attribute a pre-existing email of an unrelated role to this run. Report it yourself in the SAME turn, without a reprompt and without deferring to a scheduled re-check: name which of the just-enriched contacts now have emails / phones, the done/total counts, and \u2014 if \`bulk_progress\` is present \u2014 any \`quota_hit_count\` (if non-zero, say some contacts were skipped because the quota window was exhausted, and point to \`leadbay_account_status\` for the wait-or-top-up choice). If you stopped on a plateau (not \`all_done\`), say so plainly \u2014 report the resolved contacts and name the ones that didn't resolve, keyed to the requested channel and the returned fields (no \`email\` \u2192 "no email found"; no \`phone_number\` \u2192 "no phone number found") \u2014 rather than implying the job fully finished. Then show refreshed quota via \`leadbay_account_status\` (see QUOTA above); do NOT print a credits-remaining line.
4427
4611
  `;
4428
- var leadbay_bulk_qualify_leads = `Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass \`wait_for_completion:false\` to return quickly with \`{status:'running', qualify_id}\`; poll leadbay_qualify_status with that id. With \`wait_for_completion\` omitted/true, the legacy behavior polls until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null \`ai_agent_lead_score\`) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads.
4612
+ var leadbay_bulk_qualify_leads = `Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass \`wait_for_completion:false\` to return quickly with \`{status:'running', notification_id}\`; poll leadbay_qualify_status with that id. With \`wait_for_completion\` omitted/true, the legacy behavior polls until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null \`ai_agent_lead_score\`) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads.
4429
4613
 
4430
- **Default to \`wait_for_completion:false\`** for any \`count > 5\` or when chained inside a multi-phase workflow \u2014 the blocking default can hit the MCP per-call timeout and surface as \`"Request timed out"\` even when the server is still working fine. The async pattern (capture \`qualify_id\`, poll \`leadbay_qualify_status\` every ~10s) is timeout-proof. Reserve the blocking form for tiny single-digit counts in interactive use.
4614
+ **Default to \`wait_for_completion:false\`** for any \`count > 5\` or when chained inside a multi-phase workflow \u2014 the blocking default can hit the MCP per-call timeout and surface as \`"Request timed out"\` even when the server is still working fine. The async pattern (capture \`notification_id\`, poll \`leadbay_qualify_status\` every ~10s) is timeout-proof. Reserve the blocking form for tiny single-digit counts in interactive use.
4431
4615
 
4432
4616
  Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads below the top ~10 are NOT worse \u2014 the system is saving resources. This tool is how the agent spends more resources to go deeper on promising-looking leads the user hasn't had time to surface yet.
4433
4617
 
@@ -4435,6 +4619,36 @@ WHEN TO USE: when the user wants more qualified leads than what's currently show
4435
4619
 
4436
4620
  WHEN NOT TO USE: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).
4437
4621
 
4622
+ ## A launched job cannot be stopped
4623
+
4624
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
4625
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
4626
+ running result, that work is queued on Leadbay and runs to completion, and the
4627
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
4628
+ launched nothing and is not covered here.
4629
+
4630
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
4631
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
4632
+ work stopped. What to do next depends on what you are holding:
4633
+
4634
+ - **A handle.** Poll the status tool with it, and do not launch the work that
4635
+ handle covers a second time \u2014 that spends the quota again on the same rows.
4636
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
4637
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
4638
+ notification of its own: resume it with
4639
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
4640
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
4641
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
4642
+ for what was launched and re-run for that subset only, never for the whole
4643
+ batch.
4644
+ - **No result at all**, because the call timed out or the stream closed before it
4645
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
4646
+ finished. Calling the same tool again with the same arguments will usually hand
4647
+ back the job already launched rather than starting a second one, but that guard
4648
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
4649
+ are about to re-run before you spend the user's quota on it.
4650
+
4651
+
4438
4652
  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\`.
4439
4653
 
4440
4654
 
@@ -4456,7 +4670,7 @@ After the status line, propose the obvious refresh / progress-check / recovery a
4456
4670
 
4457
4671
  Specifically for bulk qualify:
4458
4672
 
4459
- - Kicked off async \u2192 \`"\u2713 Qualifying N lead(s) (qualify_id <id>) \u2014 typically ~M minutes. I'll refresh your leads view when it's done."\`
4673
+ - Kicked off async \u2192 \`"\u2713 Qualifying N lead(s) (notification_id <id>) \u2014 typically ~M minutes. I'll refresh your leads view when it's done."\`
4460
4674
  - Blocking call returned with answers \u2192 \`"\u2713 Qualified N lead(s). Refresh your leads to see the new \u2756 caps."\`
4461
4675
  - Already-qualified short-circuit \u2192 \`"All N leads are already qualified \u2014 no work to do."\`
4462
4676
  - 429 mid-fanout \u2192 \`"\u26A0 Rate-limited after launching M of N \u2014 already-launched leads will complete; re-call later for the rest."\`
@@ -4919,6 +5133,23 @@ WHEN TO USE: when the user has already picked WHO they want on a company and you
4919
5133
 
4920
5134
  WHEN NOT TO USE: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow. Not to mark someone as the priority contact \u2014 that is leadbay_pin_contact, and pinning does not enrich anyone.
4921
5135
 
5136
+ ## A launched job cannot be stopped, and this tool has no retry guard
5137
+
5138
+ Leadbay has no cancel. Once this call returns having actually launched, the work
5139
+ is queued on Leadbay and runs to completion, and the quota it costs is already
5140
+ committed. A \`dry_run\` result reached no backend and spent nothing. The user
5141
+ cancelling in the chat, a request timeout, or a closed stream stops YOUR waiting,
5142
+ never the job.
5143
+
5144
+ Unlike the composite launchers, this tool has **no double-launch guard**: calling
5145
+ it again always issues a new paid launch, even seconds later with identical
5146
+ arguments. So when a call returns nothing at all, do not simply retry. Read the
5147
+ record back first \u2014 \`leadbay_research_lead_by_id\` or \`leadbay_get_contacts\` for a
5148
+ lead, \`leadbay_account_status\` for background work that has since finished \u2014 to
5149
+ see whether the launch already landed, and tell the user what you are about to
5150
+ spend before spending it again.
5151
+
5152
+
4922
5153
  ## QUOTA, NOT CREDITS
4923
5154
 
4924
5155
  Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead. The actual per-contact cost (\`enrichment.credits_used\`) appears on the contact after enrichment.
@@ -4933,6 +5164,36 @@ WHEN TO USE: as the agent's go-to enrichment entry point, immediately before pro
4933
5164
 
4934
5165
  WHEN NOT TO USE: to enrich a single named contact \u2014 that's leadbay_enrich_contacts. Speculatively, before the user has committed to outreaching \u2014 enrichment consumes quota. **NOT to add "titles" or "LinkedIn" to a list** \u2014 a contact's \`job_title\` and \`linkedin_page\` already ride on the contact record; they are FREE and need no enrichment. If the user asks for "title and LinkedIn only", read those fields directly (e.g. leadbay_get_contacts / leadbay_research_lead_by_id); do NOT launch a job here. This tool is strictly the email / phone reveal, which consumes quota.
4935
5166
 
5167
+ ## A launched job cannot be stopped
5168
+
5169
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
5170
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
5171
+ running result, that work is queued on Leadbay and runs to completion, and the
5172
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
5173
+ launched nothing and is not covered here.
5174
+
5175
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
5176
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
5177
+ work stopped. What to do next depends on what you are holding:
5178
+
5179
+ - **A handle.** Poll the status tool with it, and do not launch the work that
5180
+ handle covers a second time \u2014 that spends the quota again on the same rows.
5181
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
5182
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
5183
+ notification of its own: resume it with
5184
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
5185
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
5186
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
5187
+ for what was launched and re-run for that subset only, never for the whole
5188
+ batch.
5189
+ - **No result at all**, because the call timed out or the stream closed before it
5190
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
5191
+ finished. Calling the same tool again with the same arguments will usually hand
5192
+ back the job already launched rather than starting a second one, but that guard
5193
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
5194
+ are about to re-run before you spend the user's quota on it.
5195
+
5196
+
4936
5197
  ## ENRICHMENT CONSUMES QUOTA \u2014 the model to reason with
4937
5198
 
4938
5199
  Each email reveal and each phone reveal **consumes quota** (the per-window daily / weekly / monthly allowance shown in \`leadbay_account_status\`). That is the ONLY thing that gates enrichment. Do **NOT** reason about, mention, or block on "credits": there is no separate credit wall the user must clear first \u2014 enrichment either fits the user's remaining quota or the backend returns 429 (\`{status:'quota_exceeded'}\`) when a window is actually exhausted. **Never pre-refuse enrichment because a credit number looks low or zero** \u2014 a fresh/freemium account with quota still available can enrich even when its credit counter reads 0. If and only if the backend returns \`quota_exceeded\`, tell the user which window is exhausted and offer the wait-or-top-up choice (see \`leadbay_account_status\`).
@@ -4955,7 +5216,7 @@ Do NOT rely on a bare call (no \`confirm\`, no \`dry_run\`, no channels) as a "s
4955
5216
 
4956
5217
  ## AFTER LAUNCH \u2014 STAY ACTIVE UNTIL DONE
4957
5218
 
4958
- When a launch returns \`mode:"launched"\` with a \`bulk_id\`, the enrichment runs ASYNC on the backend \u2014 the tool returns immediately, before any email/phone is attached. **Unless the user explicitly said to start it in the background / not to wait** (e.g. "kick it off, I'll check later", "don't wait for it"), stay active and report in-turn \u2014 do NOT end your turn on the ack, and do NOT say "I'll let you know when it's done." (If the user DID ask you not to wait, honor that: hand back the \`bulk_id\` and a one-line "running \u2014 you can ask any time". Only promise that completion will auto-surface via \`_meta.notifications\` when the launch returned a non-null \`notification_id\`; if \`notification_id\` is null (the nullable-backend path), say instead that you'll re-check when asked / they should ask again later \u2014 nothing surfaces automatically without a notification id. Don't force a poll loop against explicit intent.) In the default (stay-active) case: call \`leadbay_bulk_enrich_status({bulk_id})\` in a loop, re-polling until the job is done (small batches typically finish in under ~2 min). Pass \`include_contacts:true\` on the read you intend to report from, so you get each lead's enriched contacts back. Note that \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), which can include contacts of OTHER roles that were already enriched in earlier runs \u2014 so **filter your report to the \`titles\` you just enriched** (match each contact's \`job_title\` to the requested titles). Don't present a pre-existing CFO/Sales email as part of this CEO/Owner/Manager run. Then \u2014 on your own, without waiting for the user to reprompt \u2014 report the enrichment: which of the just-enriched contacts now have emails / phones, and the counts from \`overall_progress\` (\`done\`/\`total\`). \`leadbay_bulk_enrich_status\` also returns \`bulk_progress.success_count\` / \`failure_count\` / \`quota_hit_count\` on the notification fast path \u2014 use those when present, but the legacy per-lead fallback returns \`overall_progress\` only, so don't assume \`bulk_progress\` exists (see the status tool's COMPLETION REPORT). Then show refreshed quota via \`leadbay_account_status\` (see AFTER above).
5219
+ When a launch returns \`mode:"launched"\` with a \`notification_id\`, the enrichment runs ASYNC on the backend \u2014 the tool returns immediately, before any email/phone is attached. **Unless the user explicitly said to start it in the background / not to wait** (e.g. "kick it off, I'll check later", "don't wait for it"), stay active and report in-turn \u2014 do NOT end your turn on the ack, and do NOT say "I'll let you know when it's done." (If the user DID ask you not to wait, honor that: hand back the \`notification_id\` and a one-line "running \u2014 you can ask any time". Only promise that completion will auto-surface via \`_meta.notifications\` when the launch returned a non-null \`notification_id\`; if \`notification_id\` is null (the nullable-backend path), say instead that you'll re-check when asked / they should ask again later \u2014 nothing surfaces automatically without a notification id. Don't force a poll loop against explicit intent.) In the default (stay-active) case: call \`leadbay_bulk_enrich_status({notification_id})\` in a loop, re-polling until the job is done (small batches typically finish in under ~2 min). Pass \`include_contacts:true\` on the read you intend to report from, so you get each lead's enriched contacts back. Note that \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), which can include contacts of OTHER roles that were already enriched in earlier runs \u2014 so **filter your report to the \`titles\` you just enriched** (match each contact's \`job_title\` to the requested titles). Don't present a pre-existing CFO/Sales email as part of this CEO/Owner/Manager run. Then \u2014 on your own, without waiting for the user to reprompt \u2014 report the enrichment: which of the just-enriched contacts now have emails / phones, and the counts from \`overall_progress\` (\`done\`/\`total\`). \`leadbay_bulk_enrich_status\` also returns \`bulk_progress.success_count\` / \`failure_count\` / \`quota_hit_count\` on the notification fast path \u2014 use those when present, but the per-lead path returns \`overall_progress\` only, so don't assume \`bulk_progress\` exists (see the status tool's COMPLETION REPORT). Then show refreshed quota via \`leadbay_account_status\` (see AFTER above).
4959
5220
 
4960
5221
  **"Done" = \`all_done:true\` OR the resolvable work has plateaued.** Keep polling while \`overall_progress.done\` is still climbing. But \`total\` counts every matching contact, and some (unresolvable titles, contacts with no findable email) never flip to done \u2014 so a job can sit below 100% with \`all_done:false\` forever. A plateau is only real once the job has had time to run: do NOT declare it from the first few back-to-back reads (early on \`done\` can sit at its initial value while the backend is still spinning the job up). Give it at least ~90s\u20132 min of actual elapsed polling \u2014 space your polls out (~15\u201330s apart) rather than firing them back-to-back \u2014 and only treat the set as complete when \`overall_progress.done\` has held steady across several spaced polls over that window. Then stop polling and report what resolved, naming the ones that didn't. Key the "didn't resolve" wording off the channels the user actually requested and the returned contact fields (contacts carry \`email\` and \`phone_number\`) \u2014 a contact enriched for phone that came back with no \`phone_number\` is "no phone number found", one with no \`email\` is "no email found", email+phone that got neither is "no contact details found"; if \`quota_hit_count\` is non-zero say those were skipped because the quota window was exhausted. Do NOT hard-label every non-success as "no email found" when phone was requested. Do NOT spin indefinitely waiting for \`all_done\` on contacts the engine won't resolve, and do NOT \`ScheduleWakeup\` / defer the finished list to a later turn \u2014 deliver the resolved results in THIS reply.
4961
5222
 
@@ -5608,9 +5869,39 @@ WHEN TO USE: agent has a list of companies (domains, or CSV-shaped rows from the
5608
5869
 
5609
5870
  WHEN NOT TO USE: discovery (use leadbay_pull_leads); single-lead deep dive (use leadbay_research_lead_by_id); high-cadence or untrusted automation \u2014 this mutates user state and consumes ai_rescore + web_fetch quota.
5610
5871
 
5611
- 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.
5872
+ ## A launched job cannot be stopped
5873
+
5874
+ Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
5875
+ \`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
5876
+ running result, that work is queued on Leadbay and runs to completion, and the
5877
+ quota it costs is already committed. A discovery, preview or \`dry_run\` result
5878
+ launched nothing and is not covered here.
5879
+
5880
+ The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
5881
+ waiting, never the job. \`cancelled: true\` means we stopped watching, not that the
5882
+ work stopped. What to do next depends on what you are holding:
5883
+
5884
+ - **A handle.** Poll the status tool with it, and do not launch the work that
5885
+ handle covers a second time \u2014 that spends the quota again on the same rows.
5886
+ \`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
5887
+ under that name. A qualification started by \`leadbay_import_and_qualify\` has no
5888
+ notification of its own: resume it with
5889
+ \`leadbay_qualify_status({lead_ids, lens_id})\`.
5890
+ - **A handle AND a subset the result says never started** \u2014 \`failed[]\` entries
5891
+ with \`error:"not_queued"\`, or a \`rows_pending_upload\` count. Poll the handle
5892
+ for what was launched and re-run for that subset only, never for the whole
5893
+ batch.
5894
+ - **No result at all**, because the call timed out or the stream closed before it
5895
+ returned. Check \`leadbay_account_status\` first: the launch may have landed and
5896
+ finished. Calling the same tool again with the same arguments will usually hand
5897
+ back the job already launched rather than starting a second one, but that guard
5898
+ is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
5899
+ are about to re-run before you spend the user's quota on it.
5612
5900
 
5613
- \`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.
5901
+
5902
+ 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[]\`, plus the ids that resume it: \`lead_ids\` + \`lens_id\` for leadbay_qualify_status, \`import_ids\` for leadbay_import_status. There is no qualification \`notification_id\` \u2014 the qualify phase runs per-lead, so no job notification exists; \`notification_ids[]\` are the file-import ones. Idempotent within a 5-min window. \`dry_run:'preview'\` returns mapping hints + custom-field candidates without importing.
5903
+
5904
+ \`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.
5614
5905
 
5615
5906
  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\`.
5616
5907
 
@@ -5635,9 +5926,9 @@ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before yo
5635
5926
  **Header \u2014 single line, choose by status:**
5636
5927
 
5637
5928
  - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
5638
- - Running, \`handle_id\` present: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
5639
- - 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."\`
5640
- - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
5929
+ - Running: \`"\u23F3 Import running \u2014 importIds <ids>; poll leadbay_import_status"\`
5930
+ - Running with \`timed_out:true\` (the 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."\`
5931
+ - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 I'll pick it up with leadbay_qualify_status"\` (its resume ids are \`lead_ids\` + \`lens_id\`; there is no qualification notification_id to quote)
5641
5932
 
5642
5933
  Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
5643
5934
 
@@ -5685,7 +5976,7 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
5685
5976
 
5686
5977
  | Observation | Suggest | Calls |
5687
5978
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
5688
- | Status: running, \`handle_id\` present | "Check progress" | leadbay_import_status(handle_id) |
5979
+ | Status: running | "Check progress" | leadbay_import_status(importIds) |
5689
5980
  | 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 |
5690
5981
  | \`rows_pending_upload\` present | "Import the rows that never got submitted" | leadbay_import_leads (that subset only) |
5691
5982
  | 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 |
@@ -5695,9 +5986,9 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
5695
5986
  | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
5696
5987
  | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
5697
5988
  `;
5698
- 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.
5989
+ 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', importIds}\`; 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.
5699
5990
 
5700
- 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.
5991
+ 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. Leadbay has no cancel, so a Cancel or timeout is no reason to call it either. Sole exception: a \`wait_for_completion:false\` call that returned NOTHING \u2014 and even that can re-upload, so check CRM-imports. 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.
5701
5992
 
5702
5993
  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.
5703
5994
 
@@ -5732,9 +6023,9 @@ Otherwise, partition \`not_imported\` by \`reason\` into these buckets before yo
5732
6023
  **Header \u2014 single line, choose by status:**
5733
6024
 
5734
6025
  - Completed: \`"\u2713 Import complete \u2014 N imported \xB7 P pending crawl \xB7 Q need attention"\` (drop any segment whose count is 0)
5735
- - Running, \`handle_id\` present: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
5736
- - 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."\`
5737
- - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
6026
+ - Running: \`"\u23F3 Import running \u2014 importIds <ids>; poll leadbay_import_status"\`
6027
+ - Running with \`timed_out:true\` (the 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."\`
6028
+ - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 I'll pick it up with leadbay_qualify_status"\` (its resume ids are \`lead_ids\` + \`lens_id\`; there is no qualification notification_id to quote)
5738
6029
 
5739
6030
  Count \`uncrawled\` rows as **pending**, never as failures \u2014 never say "M failed" when the M is mostly/entirely uncrawled rows.
5740
6031
 
@@ -5782,7 +6073,7 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
5782
6073
 
5783
6074
  | Observation | Suggest | Calls |
5784
6075
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
5785
- | Status: running, \`handle_id\` present | "Check progress" | leadbay_import_status(handle_id) |
6076
+ | Status: running | "Check progress" | leadbay_import_status(importIds) |
5786
6077
  | 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 |
5787
6078
  | \`rows_pending_upload\` present | "Import the rows that never got submitted" | leadbay_import_leads (that subset only) |
5788
6079
  | 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 |
@@ -5792,11 +6083,45 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
5792
6083
  | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
5793
6084
  | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
5794
6085
  `;
5795
- 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.
6086
+ var leadbay_import_status = `Retrieve the current **status/progress** of a lead import, and its leadIds once it finishes. Pass the \`importIds\` the launch returned \u2014 \`leadbay_import_leads\` returns \`importIds\`, \`leadbay_import_and_qualify\` returns \`import_ids\`. These are the backend's own import ids, so they resolve from a later message, a later conversation, or the next day; nothing is stored on the MCP side. Also pass the \`dry_run\` the import was launched with, so completion is judged against the right phase (a dry run finishes at preprocess, a real import at processing). This status call performs a single refresh pass and never polls in a loop.
6087
+
6088
+ WHEN TO USE: after an async import returns its ids \u2014 \`leadbay_import_leads\` as \`{status:'running', importIds}\`, \`leadbay_import_and_qualify\` as \`import_ids\` \u2014 poll with those; 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).
6089
+
6090
+ WHEN NOT TO USE: for the qualification half \u2014 use leadbay_qualify_status, with the \`lead_ids\` + \`lens_id\` an \`leadbay_import_and_qualify\` launch returned (it has no qualification \`notification_id\`; its \`notification_ids[]\` are these same file imports); or when you still want the legacy blocking behavior from leadbay_import_leads with \`wait_for_completion=true\`.
6091
+
6092
+ ## A launched job cannot be stopped
6093
+
6094
+ Leadbay has no cancel. A job started by \`leadbay_enrich_titles\`,
6095
+ \`leadbay_bulk_qualify_leads\`, \`leadbay_import_leads\` or
6096
+ \`leadbay_import_and_qualify\` runs to completion on Leadbay. The user cancelling
6097
+ in the chat, a request timeout, or a closed stream stops YOUR waiting, never the
6098
+ job, and \`cancelled: true\` on an earlier result means we stopped watching, not
6099
+ that the work stopped.
6100
+
6101
+ **This tool only reads.** Calling it again launches nothing and spends no quota,
6102
+ so poll it as often as the job needs \u2014 a timeout here is a reason to call it
6103
+ again, not a reason to stop.
5796
6104
 
5797
- 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).
6105
+ One import state does NOT progress: a chunk cancelled before its mappings were
6106
+ committed reads \`running\` / \`committing\` forever. If the counts hold flat across
6107
+ several spaced polls, say so and stop, rather than polling on.
6108
+
6109
+ What must not be repeated is the LAUNCH \u2014 for work that actually launched. Re-run
6110
+ a launcher only for a subset that never started, never for the whole batch:
6111
+
6112
+ - \`failed[]\` entries with \`error:"not_queued"\`;
6113
+ - a \`rows_pending_upload\` count;
6114
+ - leads in \`still_running\` after a CANCELLED \`leadbay_import_and_qualify\`. Its
6115
+ fan-out is sequential, so an interruption leaves the remainder unlaunched and
6116
+ folds them in with the ones that did launch. Nothing in the result tells the
6117
+ two apart, and this tool cannot start either. Wait until the REST of the batch
6118
+ has settled: what launched settles in order, so leads still unanswered after
6119
+ that are the ones that never started. Only then call
6120
+ \`leadbay_bulk_qualify_leads({leadIds, lensId})\` for exactly those ids. A lead
6121
+ that is merely slow looks identical to one that never launched over a few
6122
+ polls, and re-launching it charges the user twice \u2014 when unsure, tell the user
6123
+ rather than guess.
5798
6124
 
5799
- WHEN NOT TO USE: for qualification handles returned as \`qualify_id\` \u2014 use leadbay_qualify_status for those; or when you still want the legacy blocking behavior from leadbay_import_leads with \`wait_for_completion=true\`.
5800
6125
 
5801
6126
  ---
5802
6127
 
@@ -5816,12 +6141,11 @@ After the status line, propose the obvious refresh / progress-check / recovery a
5816
6141
 
5817
6142
  Specifically for import status:
5818
6143
 
5819
- 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.**
6144
+ This tool returns \`status\`, \`importIds\`, and \`progress\` ({phase, records_processed, records_total}). Once every named import is \`complete\` and it wasn't a dry run, it also reconciles the wizard's records and carries \`result\` ({leads, not_imported, importIds, still_settling?}) \u2014 that is how you recover the leadIds of an import you stopped watching, 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. **Render only from the fields actually present; never invent counts.**
5820
6145
 
5821
6146
  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.
5822
6147
 
5823
- - 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.
5824
- - 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.
6148
+ - 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"\` means the mappings are still being committed \u2014 say it's still being committed, never that it failed or finished empty.
5825
6149
  - 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.
5826
6150
  - 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.
5827
6151
  - 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.
@@ -5853,12 +6177,29 @@ How the OTHER reasons map to the "Need attention" bucket (see the render block a
5853
6177
  | 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) |
5854
6178
  | Status: error / failed (true error) | "Diagnose the failure" | leadbay_resolve_import_rows |
5855
6179
  `;
5856
- 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.
6180
+ 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 notification_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.
5857
6181
 
5858
6182
  WHEN TO USE: low-level.
5859
6183
 
5860
6184
  WHEN NOT TO USE: from agent flow \u2014 leadbay_enrich_titles handles selection lifecycle, preview, launch, and cleanup.
5861
6185
 
6186
+ ## A launched job cannot be stopped, and this tool has no retry guard
6187
+
6188
+ Leadbay has no cancel. Once this call returns having actually launched, the work
6189
+ is queued on Leadbay and runs to completion, and the quota it costs is already
6190
+ committed. A \`dry_run\` result reached no backend and spent nothing. The user
6191
+ cancelling in the chat, a request timeout, or a closed stream stops YOUR waiting,
6192
+ never the job.
6193
+
6194
+ Unlike the composite launchers, this tool has **no double-launch guard**: calling
6195
+ it again always issues a new paid launch, even seconds later with identical
6196
+ arguments. So when a call returns nothing at all, do not simply retry. Read the
6197
+ record back first \u2014 \`leadbay_research_lead_by_id\` or \`leadbay_get_contacts\` for a
6198
+ lead, \`leadbay_account_status\` for background work that has since finished \u2014 to
6199
+ see whether the launch already landed, and tell the user what you are about to
6200
+ spend before spending it again.
6201
+
6202
+
5862
6203
  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\`.
5863
6204
  `;
5864
6205
  var leadbay_like_lead = `## WHEN TO USE
@@ -6928,13 +7269,70 @@ WHEN TO USE: low-level \u2014 when you need to kick qualification on exactly one
6928
7269
 
6929
7270
  WHEN NOT TO USE: as the agent's bulk-qualify path \u2014 use leadbay_bulk_qualify_leads, which paginates past already-qualified leads, fans out, polls, and bails out cleanly on 429.
6930
7271
 
7272
+ ## A launched job cannot be stopped, and this tool has no retry guard
7273
+
7274
+ Leadbay has no cancel. Once this call returns having actually launched, the work
7275
+ is queued on Leadbay and runs to completion, and the quota it costs is already
7276
+ committed. A \`dry_run\` result reached no backend and spent nothing. The user
7277
+ cancelling in the chat, a request timeout, or a closed stream stops YOUR waiting,
7278
+ never the job.
7279
+
7280
+ Unlike the composite launchers, this tool has **no double-launch guard**: calling
7281
+ it again always issues a new paid launch, even seconds later with identical
7282
+ arguments. So when a call returns nothing at all, do not simply retry. Read the
7283
+ record back first \u2014 \`leadbay_research_lead_by_id\` or \`leadbay_get_contacts\` for a
7284
+ lead, \`leadbay_account_status\` for background work that has since finished \u2014 to
7285
+ see whether the launch already landed, and tell the user what you are about to
7286
+ spend before spending it again.
7287
+
7288
+
6931
7289
  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\`.
6932
7290
  `;
6933
- var leadbay_qualify_status = `Retrieve the current state of an import_and_qualify (or bulk_qualify_leads) launch by \`qualify_id\`. Returns the same \`qualified[]\` / \`still_running[]\` shape as the original composite, refreshed against the backend at call time. The handle is persisted to \`~/.leadbay/bulks.json\` with a 30-day TTL and survives MCP restart.
7291
+ var leadbay_qualify_status = `Retrieve the current state of a bulk_qualify_leads or import_and_qualify launch. Which ids to pass depends on which tool launched it, because only one of them creates a qualification job on the backend:
7292
+
7293
+ - **\`leadbay_bulk_qualify_leads\`** returns a \`notification_id\`. Pass it for progress in ONE call, and add the \`lead_ids\` + \`lens_id\` it also returned for per-lead detail (which settled, which are still running).
7294
+ - **\`leadbay_import_and_qualify\`** returns NO qualification \`notification_id\` \u2014 its qualify phase runs per-lead, so no job notification exists. Pass the \`lead_ids\` + \`lens_id\` it returned. Its \`notification_ids[]\` are the FILE-IMPORT notifications; handing one of those to this tool is rejected as the wrong kind, and \`leadbay_import_status({importIds})\` is where the import half is polled.
6934
7295
 
6935
- WHEN TO USE: after leadbay_import_and_qualify or leadbay_bulk_qualify_leads returned a \`qualify_id\` with non-empty \`still_running[]\`, call this tool a few minutes later (or hours) to retrieve the now-completed qualifications without re-running the import or re-spending qualify quota.
7296
+ **When it is finished:** \`status\` is always \`"launched"\` \u2014 it is not a progress field. On the \`notification_id\` path the job is done when \`in_progress\` is false (or \`bulk_progress.success_count + failure_count\` reaches \`total_count\`); \`still_running[]\` is empty on that path from the very first poll and must NOT be read as "done". On the \`lead_ids\` path the job is done when \`still_running[]\` is empty. Pass both and you get both signals in one call.
7297
+
7298
+ Everything comes straight out of the launch response \u2014 nothing is stored on the MCP side. A backend job is scoped to the user who launched it, so a \`notification_id\` resolves from a later message, a later conversation, or the next day.
7299
+
7300
+ WHEN TO USE: after leadbay_bulk_qualify_leads or leadbay_import_and_qualify came back with a non-empty \`still_running[]\`, call this tool a few minutes later (or hours) with those ids to retrieve the now-completed qualifications without re-running the import or re-spending qualify quota.
6936
7301
 
6937
7302
  WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id \u2014 that's a deeper per-lead profile and includes contacts. This tool is purely the qualification answers + signals_count.
7303
+
7304
+ ## A launched job cannot be stopped
7305
+
7306
+ Leadbay has no cancel. A job started by \`leadbay_enrich_titles\`,
7307
+ \`leadbay_bulk_qualify_leads\`, \`leadbay_import_leads\` or
7308
+ \`leadbay_import_and_qualify\` runs to completion on Leadbay. The user cancelling
7309
+ in the chat, a request timeout, or a closed stream stops YOUR waiting, never the
7310
+ job, and \`cancelled: true\` on an earlier result means we stopped watching, not
7311
+ that the work stopped.
7312
+
7313
+ **This tool only reads.** Calling it again launches nothing and spends no quota,
7314
+ so poll it as often as the job needs \u2014 a timeout here is a reason to call it
7315
+ again, not a reason to stop.
7316
+
7317
+ One import state does NOT progress: a chunk cancelled before its mappings were
7318
+ committed reads \`running\` / \`committing\` forever. If the counts hold flat across
7319
+ several spaced polls, say so and stop, rather than polling on.
7320
+
7321
+ What must not be repeated is the LAUNCH \u2014 for work that actually launched. Re-run
7322
+ a launcher only for a subset that never started, never for the whole batch:
7323
+
7324
+ - \`failed[]\` entries with \`error:"not_queued"\`;
7325
+ - a \`rows_pending_upload\` count;
7326
+ - leads in \`still_running\` after a CANCELLED \`leadbay_import_and_qualify\`. Its
7327
+ fan-out is sequential, so an interruption leaves the remainder unlaunched and
7328
+ folds them in with the ones that did launch. Nothing in the result tells the
7329
+ two apart, and this tool cannot start either. Wait until the REST of the batch
7330
+ has settled: what launched settles in order, so leads still unanswered after
7331
+ that are the ones that never started. Only then call
7332
+ \`leadbay_bulk_qualify_leads({leadIds, lensId})\` for exactly those ids. A lead
7333
+ that is merely slow looks identical to one that never launched over a few
7334
+ polls, and re-launching it charges the user twice \u2014 when unsure, tell the user
7335
+ rather than guess.
6938
7336
  `;
6939
7337
  var leadbay_recall_ordered_titles = `Show job titles the org has previously enriched, so the agent can repeat the same titles for new leads (or skip already-saturated ones). Two implementation paths: (1) PREFERRED \u2014 a selection-scoped preview call that reads \`previously_enriched_titles\` from the backend (newer prod field). (2) FALLBACK \u2014 live aggregation across each lead's enriched contacts. The composite picks transparently.
6940
7338
 
@@ -8530,7 +8928,7 @@ Some Leadbay tool responses include a \`_meta.notifications\` array listing **ba
8530
8928
  - \`leadbay_qualify_status\` \u2192 \`still_running\` is empty: every launched lead has finished or failed. (\`in_progress\` also reads \`false\` on the fast path, but it can be \`null\` on the legacy/fallback read \u2014 so treat an empty \`still_running\` as terminal on its own; only require \`in_progress:false\` when that field is actually present.) LIKE imports, large qualification runs are async by design: \`leadbay_bulk_qualify_leads\` defaults to \`wait_for_completion:false\` for \`count > 5\` or chained workflows because blocking can time out, and \`leadbay_qualify_status\` may take minutes/hours. So don't force a long polling loop on a big run \u2014 return the handle/progress and let completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked to wait, or it's a small run that finishes quickly. A small \`wait_for_completion:true\` run you can poll to \`still_running\` empty inline.
8531
8929
  - \`leadbay_import_status\` \u2192 \`status:"complete"\` (or \`"failed"\`). BUT imports are the exception to the stay-active loop: a large \`leadbay_import_leads({wait_for_completion:false})\` is meant to return a handle and resolve over minutes, and the tool does ONE refresh pass per call. Don't block the conversation looping on it \u2014 surface the returned progress/handle and let the completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked you to wait for the import, or it's a small import that finishes quickly.
8532
8930
 
8533
- Enrichment polls to completion in-turn BY DEFAULT \u2014 the exception is when the user explicitly said to start it in the background / not wait ("kick it off, I'll check later"), in which case hand back the bulk_id and let completion arrive via \`_meta.notifications\` (only when a notification id exists; if none was returned, tell the user to ask again / that you'll poll later, since nothing will auto-surface). For qualification and imports, poll inline only for small/quick runs or when the user explicitly asked you to wait; otherwise return the handle and let \`_meta.notifications\` deliver it. Either way, the user should never have to ask "is it done yet?" for work you kicked off in the same turn \u2014 you either report it or hand back a clear in-progress handle.
8931
+ Enrichment polls to completion in-turn BY DEFAULT \u2014 the exception is when the user explicitly said to start it in the background / not wait ("kick it off, I'll check later"), in which case hand back the notification_id and let completion arrive via \`_meta.notifications\` (only when a notification id exists; if none was returned, tell the user to ask again / that you'll poll later, since nothing will auto-surface). For qualification and imports, poll inline only for small/quick runs or when the user explicitly asked you to wait; otherwise return the handle and let \`_meta.notifications\` deliver it. Either way, the user should never have to ask "is it done yet?" for work you kicked off in the same turn \u2014 you either report it or hand back a clear in-progress handle.
8534
8932
 
8535
8933
  Also surfaced as a top-level \`notifications\` array on \`leadbay_account_status\` \u2014 same shape, same handling.
8536
8934
 
@@ -9386,7 +9784,9 @@ var qualifyLead = {
9386
9784
  title: "Qualify a single lead",
9387
9785
  readOnlyHint: false,
9388
9786
  destructiveHint: true,
9389
- idempotentHint: true,
9787
+ // No double-launch guard: this POSTs straight through, so an identical
9788
+ // repeat is a second paid launch, not a no-op (product#4039).
9789
+ idempotentHint: false,
9390
9790
  openWorldHint: true
9391
9791
  },
9392
9792
  description: leadbay_qualify_lead,
@@ -9448,7 +9848,9 @@ var enrichContacts = {
9448
9848
  title: "Enrich contacts for a lead",
9449
9849
  readOnlyHint: false,
9450
9850
  destructiveHint: true,
9451
- idempotentHint: true,
9851
+ // No double-launch guard: this POSTs straight through, so an identical
9852
+ // repeat is a second paid launch, not a no-op (product#4039).
9853
+ idempotentHint: false,
9452
9854
  openWorldHint: true
9453
9855
  },
9454
9856
  description: leadbay_enrich_contacts,
@@ -11413,7 +11815,66 @@ async function refreshLeadStates(client, leadIds, questionOrder) {
11413
11815
  }
11414
11816
 
11415
11817
  // ../core/dist/composite/import-leads.js
11416
- import { createHash as createHash2, randomUUID } from "crypto";
11818
+ import { createHash as createHash3, randomUUID } from "crypto";
11819
+
11820
+ // ../core/dist/jobs/launch-guard.js
11821
+ import { createHash as createHash2 } from "crypto";
11822
+ var WINDOW_MS = 5 * 60 * 1e3;
11823
+ var MAX_ENTRIES = 1e3;
11824
+ var recent = /* @__PURE__ */ new Map();
11825
+ function launchFingerprint(parts) {
11826
+ const flat = parts.map((p) => Array.isArray(p) ? [...p].sort().join(",") : String(p)).join("|");
11827
+ return createHash2("sha256").update(flat).digest("hex");
11828
+ }
11829
+ function sweep(now) {
11830
+ for (const [k, v] of recent) {
11831
+ if (now - v.at >= WINDOW_MS)
11832
+ recent.delete(k);
11833
+ }
11834
+ }
11835
+ function beginLaunch(fingerprint, now = Date.now()) {
11836
+ const prior = recallLaunch(fingerprint, now);
11837
+ if (prior) {
11838
+ return prior.in_flight ? { state: "in_flight", seconds_since: prior.seconds_since } : { state: "settled", record: prior };
11839
+ }
11840
+ recent.set(fingerprint, {
11841
+ notification_id: null,
11842
+ in_flight: true,
11843
+ launched_at: new Date(now).toISOString(),
11844
+ at: now
11845
+ });
11846
+ return { state: "owned" };
11847
+ }
11848
+ function abandonLaunch(fingerprint) {
11849
+ const held = recent.get(fingerprint);
11850
+ if (held?.in_flight)
11851
+ recent.delete(fingerprint);
11852
+ }
11853
+ function recallLaunch(fingerprint, now = Date.now()) {
11854
+ sweep(now);
11855
+ const hit = recent.get(fingerprint);
11856
+ if (!hit)
11857
+ return void 0;
11858
+ return { ...hit, seconds_since: Math.round((now - hit.at) / 1e3) };
11859
+ }
11860
+ function rememberLaunch(fingerprint, notificationId, now = Date.now(), importIds) {
11861
+ sweep(now);
11862
+ while (recent.size >= MAX_ENTRIES) {
11863
+ const oldest = recent.keys().next();
11864
+ if (oldest.done)
11865
+ break;
11866
+ recent.delete(oldest.value);
11867
+ }
11868
+ const rec = {
11869
+ notification_id: notificationId,
11870
+ in_flight: false,
11871
+ ...importIds ? { import_ids: importIds } : {},
11872
+ launched_at: new Date(now).toISOString(),
11873
+ at: now
11874
+ };
11875
+ recent.set(fingerprint, rec);
11876
+ return rec;
11877
+ }
11417
11878
 
11418
11879
  // ../core/dist/composite/_import-records.js
11419
11880
  function normalizeDomain(input) {
@@ -11611,10 +12072,10 @@ function reconcileRecords(records) {
11611
12072
  }
11612
12073
 
11613
12074
  // ../core/dist/composite/_import-commit-log.js
11614
- var MAX_ENTRIES = 500;
12075
+ var MAX_ENTRIES2 = 500;
11615
12076
  var failures = /* @__PURE__ */ new Map();
11616
12077
  function recordCommitFailure(importId, reason) {
11617
- if (failures.size >= MAX_ENTRIES) {
12078
+ if (failures.size >= MAX_ENTRIES2) {
11618
12079
  const oldest = failures.keys().next().value;
11619
12080
  if (oldest !== void 0)
11620
12081
  failures.delete(oldest);
@@ -11707,7 +12168,7 @@ function importFingerprint(params, prep) {
11707
12168
  mappings: prep.mappings,
11708
12169
  dry_run: Boolean(params.dry_run)
11709
12170
  };
11710
- return createHash2("sha256").update(stableStringify(payload)).digest("hex");
12171
+ return createHash3("sha256").update(stableStringify(payload)).digest("hex");
11711
12172
  }
11712
12173
  var ImportPhaseTimeout = class extends Error {
11713
12174
  phase;
@@ -12104,7 +12565,7 @@ async function commitMappings(client, importId, mappings, ctx) {
12104
12565
  throw err;
12105
12566
  }
12106
12567
  }
12107
- async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onNotificationId) {
12568
+ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseBudgetMs, totalDeadline, ctx, signal, onNotificationId, onCommitSent) {
12108
12569
  const { importId, chunk } = upload;
12109
12570
  const phaseBudget = Math.min(perPhaseBudgetMs, Math.max(1, totalDeadline - Date.now()));
12110
12571
  await pollPreprocess(client, importId, phaseBudget, ctx, signal);
@@ -12113,6 +12574,7 @@ async function completeUploadedChunk(client, upload, mappings, dryRun, perPhaseB
12113
12574
  return { importId, records: [], notification_id: null };
12114
12575
  }
12115
12576
  const importNotificationId = await commitMappings(client, importId, mappings, ctx);
12577
+ onCommitSent?.();
12116
12578
  if (importNotificationId) {
12117
12579
  onNotificationId?.(importNotificationId);
12118
12580
  ctx?.logger?.info?.(`import-leads: notification_id=${importNotificationId} importId=${importId}`);
@@ -12339,7 +12801,7 @@ var importLeads = {
12339
12801
  },
12340
12802
  wait_for_completion: {
12341
12803
  type: "boolean",
12342
- description: "When false, validate and enqueue the import in the background, then return `{status:'running', handle_id}` immediately. Poll leadbay_import_status(handle_id). Default is true for 0.6.x backwards compatibility."
12804
+ description: "When false, upload the rows and return `{status:'running', importIds}` immediately. Poll leadbay_import_status({importIds, dry_run}) \u2014 those importIds are the backend's own and keep working from any conversation. Default is true."
12343
12805
  }
12344
12806
  },
12345
12807
  // Neither field is "required" at the schema level; xor + presence is
@@ -12358,10 +12820,6 @@ var importLeads = {
12358
12820
  type: "string",
12359
12821
  description: "`running` when wait_for_completion=false; absent on the legacy blocking result."
12360
12822
  },
12361
- handle_id: {
12362
- type: "string",
12363
- 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)."
12364
- },
12365
12823
  timed_out: {
12366
12824
  type: "boolean",
12367
12825
  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."
@@ -12392,7 +12850,7 @@ var importLeads = {
12392
12850
  region: { type: "string" },
12393
12851
  cancelled: {
12394
12852
  type: "boolean",
12395
- description: "True when ctx.signal aborted the call mid-flight."
12853
+ description: "True when the HOST cancelled the call (a user Cancel, or the host's own request timeout); a Leadbay-side or phase-budget timeout reports `timed_out` instead. Rows already uploaded keep importing on Leadbay \u2014 poll leadbay_import_status with importIds, but a chunk cancelled before its mappings were committed can read `running` without ever progressing; if the counts stop moving, say so rather than polling on. Rows past the interruption may never have been uploaded; re-run the import for those rows only."
12396
12854
  },
12397
12855
  dry_run: {
12398
12856
  type: "boolean",
@@ -12460,62 +12918,72 @@ var importLeads = {
12460
12918
  }
12461
12919
  const chunks = chunkAt100(prep.validInputs);
12462
12920
  if (!waitForCompletion) {
12463
- if (!ctx?.bulkTracker) {
12464
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_leads wait_for_completion=false needs a BulkTracker so the handle survives restart.", "");
12465
- }
12466
- const reservation = await ctx.bulkTracker.findOrCreatePendingImport({
12467
- import_fingerprint: importFingerprint(params, prep),
12468
- mode: prep.mode,
12469
- dry_run: dryRun,
12470
- records_total: prep.validInputs.length
12471
- });
12472
- const importIds2 = [...reservation.record.import_ids];
12473
- const uploadedChunks = [];
12474
- if (!reservation.reused || reservation.record.import_ids.length === 0) {
12475
- try {
12476
- for (let i = 0; i < chunks.length; i++) {
12477
- const upload = await uploadOneChunk(client, chunks[i], i, chunks.length, prep.header, ctx, (id) => {
12478
- if (!importIds2.includes(id))
12479
- importIds2.push(id);
12480
- });
12481
- uploadedChunks.push(upload);
12482
- await ctx.bulkTracker.setImportIds(reservation.record.bulk_id, importIds2);
12483
- }
12484
- await ctx.bulkTracker.setImportProgress(reservation.record.bulk_id, {
12921
+ const fingerprint = launchFingerprint([
12922
+ "import",
12923
+ prep.mode,
12924
+ dryRun,
12925
+ // Cached /users/me — the admin gate above already read it, so this
12926
+ // costs no round trip. Scopes the fingerprint to the organization.
12927
+ await client.resolveOrgId(),
12928
+ importFingerprint(params, prep)
12929
+ ]);
12930
+ const claim = beginLaunch(fingerprint);
12931
+ if (claim.state === "in_flight") {
12932
+ throw client.makeError("IMPORT_LAUNCH_IN_FLIGHT", `An identical import was started ${claim.seconds_since}s ago and has not returned its import ids yet`, "Nothing was uploaded twice. Call leadbay_import_leads again with the same arguments in a few seconds to receive the importIds.", "");
12933
+ }
12934
+ const already = claim.state === "settled" ? claim.record : void 0;
12935
+ if (already) {
12936
+ return {
12937
+ status: "running",
12938
+ importIds: already.import_ids ?? [],
12939
+ notification_ids: [],
12940
+ progress: {
12485
12941
  phase: "preprocess",
12486
12942
  records_processed: 0,
12487
12943
  records_total: prep.validInputs.length
12944
+ },
12945
+ region: client.region,
12946
+ reused: true,
12947
+ seconds_since_original: already.seconds_since,
12948
+ _meta: client.lastMeta ?? {
12949
+ region: client.region,
12950
+ endpoint: "POST /imports",
12951
+ latency_ms: null,
12952
+ retry_after: null
12953
+ }
12954
+ };
12955
+ }
12956
+ const importIds2 = [];
12957
+ const uploadedChunks = [];
12958
+ try {
12959
+ for (let i = 0; i < chunks.length; i++) {
12960
+ const upload = await uploadOneChunk(client, chunks[i], i, chunks.length, prep.header, ctx, (id) => {
12961
+ if (!importIds2.includes(id))
12962
+ importIds2.push(id);
12488
12963
  });
12489
- } catch (err) {
12490
- await ctx.bulkTracker.markImportFailed(reservation.record.bulk_id, err?.message ?? err?.code ?? "unknown");
12491
- throw err;
12964
+ uploadedChunks.push(upload);
12492
12965
  }
12966
+ } catch (err) {
12967
+ abandonLaunch(fingerprint);
12968
+ throw err;
12493
12969
  }
12970
+ rememberLaunch(fingerprint, null, void 0, importIds2);
12494
12971
  if (uploadedChunks.length > 0) {
12495
- void runImportInBackground(client, prep, uploadedChunks, {
12496
- dryRun,
12497
- perPhaseBudget,
12498
- totalBudget
12499
- }, ctx, reservation.record.bulk_id);
12972
+ void runImportInBackground(client, prep, uploadedChunks, { dryRun, perPhaseBudget, totalBudget }, ctx ?? {});
12500
12973
  }
12501
12974
  return {
12502
12975
  status: "running",
12503
- handle_id: reservation.record.bulk_id,
12504
12976
  importIds: importIds2,
12505
- // Notifications fire from update_mappings, which the background
12506
- // task hasn't called yet at this point. They surface via the WS
12507
- // listener / catch-up REST on subsequent agent turns.
12977
+ // Notifications fire from update_mappings, which the background task
12978
+ // hasn't called yet. They surface via the WS listener / catch-up REST
12979
+ // on subsequent turns.
12508
12980
  notification_ids: [],
12509
12981
  progress: {
12510
- phase: reservation.record.status === "complete" ? "complete" : importIds2.length > 0 ? "preprocess" : "queued",
12511
- records_processed: reservation.record.status === "complete" ? reservation.record.records_total : 0,
12512
- records_total: reservation.record.records_total
12982
+ phase: importIds2.length > 0 ? "preprocess" : "queued",
12983
+ records_processed: 0,
12984
+ records_total: prep.validInputs.length
12513
12985
  },
12514
12986
  region: client.region,
12515
- ...reservation.reused ? {
12516
- reused: true,
12517
- seconds_since_original: reservation.seconds_since_original
12518
- } : {},
12519
12987
  _meta: client.lastMeta ?? {
12520
12988
  region: client.region,
12521
12989
  endpoint: "POST /imports",
@@ -12628,27 +13096,24 @@ function resumeParkedUpload(client, upload, mappings, ctx) {
12628
13096
  });
12629
13097
  }, 0);
12630
13098
  }
12631
- async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, handleId) {
12632
- const tracker = ctx.bulkTracker;
12633
- if (!tracker)
12634
- return;
12635
- void tracker.setImportProgress(handleId, {
12636
- phase: "preprocess",
12637
- records_processed: 0,
12638
- records_total: prep.validInputs.length
12639
- }).catch(() => {
12640
- });
13099
+ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx) {
12641
13100
  setTimeout(() => {
12642
13101
  void (async () => {
12643
- const bgCtx = { logger: ctx.logger, bulkTracker: tracker };
13102
+ const bgCtx = { logger: ctx.logger };
12644
13103
  const importIds = uploadedChunks.map((chunk) => chunk.importId);
12645
13104
  const notificationIds = [];
12646
13105
  const matched = /* @__PURE__ */ new Map();
12647
13106
  const notImported = /* @__PURE__ */ new Map();
13107
+ let inFlight = 0;
13108
+ let committed = false;
12648
13109
  try {
12649
13110
  const totalDeadline = Date.now() + opts.totalBudget;
12650
- for (const upload of uploadedChunks) {
12651
- const out = await completeUploadedChunk(client, upload, prep.mappings, opts.dryRun, opts.perPhaseBudget, totalDeadline, bgCtx, void 0);
13111
+ for (inFlight = 0; inFlight < uploadedChunks.length; inFlight++) {
13112
+ const upload = uploadedChunks[inFlight];
13113
+ committed = false;
13114
+ const out = await completeUploadedChunk(client, upload, prep.mappings, opts.dryRun, opts.perPhaseBudget, totalDeadline, bgCtx, void 0, void 0, () => {
13115
+ committed = true;
13116
+ });
12652
13117
  if (out.notification_id && !notificationIds.includes(out.notification_id)) {
12653
13118
  notificationIds.push(out.notification_id);
12654
13119
  }
@@ -12656,16 +13121,21 @@ async function runImportInBackground(client, prep, uploadedChunks, opts, ctx, ha
12656
13121
  reconcileOneChunk(prep, out, matched, notImported);
12657
13122
  }
12658
13123
  }
12659
- const result = buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false, notificationIds);
12660
- await tracker.markImportComplete(handleId, {
12661
- leads: result.leads,
12662
- not_imported: result.not_imported,
12663
- importIds: result.importIds
12664
- });
13124
+ buildImportLeadsResult(client, prep, importIds, matched, notImported, opts.dryRun, false, notificationIds);
12665
13125
  } catch (err) {
12666
- await tracker.markImportFailed(handleId, err?.message ?? err?.code ?? "unknown");
13126
+ if (!opts.dryRun) {
13127
+ const parkedFrom = committed ? inFlight + 1 : inFlight;
13128
+ for (const parked of uploadedChunks.slice(parkedFrom)) {
13129
+ resumeParkedUpload(client, parked, prep.mappings, bgCtx);
13130
+ }
13131
+ }
13132
+ ctx?.logger?.warn?.(`import-leads: background import failed for ${importIds.join(",")}: ${err?.message ?? err?.code ?? err}`);
12667
13133
  }
12668
- })();
13134
+ })().catch((e) => (
13135
+ // Terminal guard. Nothing may escape a detached task — an unhandled
13136
+ // rejection would take the whole multi-tenant hosted process down.
13137
+ ctx?.logger?.error?.(`import-leads: background import crashed: ${e?.message ?? e}`)
13138
+ ));
12669
13139
  }, 0);
12670
13140
  }
12671
13141
 
@@ -13665,7 +14135,9 @@ var launchBulkEnrichment = {
13665
14135
  title: "Launch bulk enrichment",
13666
14136
  readOnlyHint: false,
13667
14137
  destructiveHint: true,
13668
- idempotentHint: true,
14138
+ // No double-launch guard: this POSTs straight through, so an identical
14139
+ // repeat is a second paid launch, not a no-op (product#4039).
14140
+ idempotentHint: false,
13669
14141
  openWorldHint: true
13670
14142
  },
13671
14143
  description: leadbay_launch_bulk_enrichment,
@@ -17367,7 +17839,7 @@ var GETTING_STARTED_MANIFEST = {
17367
17839
  leadIds: "[<the ONE lead you drafted for at step 3>] \u2014 an ARRAY, always",
17368
17840
  lensId: "<the pinned lens id from step 2>"
17369
17841
  },
17370
- spend: "TWO BEATS \u2014 free preview FIRST, the real reveal only after the user confirms. Beat 1: call leadbay_enrich_titles with the drafted lead's id + lensId and NO titles / NO confirm / NO email / NO phone. That returns mode:'discover' \u2014 the FREE list of job titles at that company. Say plainly that nothing has been spent yet. Beat 2: name the title the draft is addressed to, tell them BEFORE they decide what it costs (one credit per contact revealed \u2014 here that is ONE contact, one credit), and ask them to confirm. Only then call leadbay_enrich_titles AGAIN with leadIds: [<that lead id>] \u2014 ALWAYS the array, even for a single lead: `leadId` singular is not a key this tool reads, so it is dropped and the paid call falls back to the default wishlist selection, charging for the whole batch \u2014 plus the chosen title, confirm:true and email:true. Poll leadbay_bulk_enrich_status with the returned bulk_id until all_done (or the count plateaus), and report the contact that actually resolved. NEVER launch the reveal without an explicit confirm: silence is not consent, and neither is 'they clicked the gate'. If they decline, keep the draft and the title and move on \u2014 that is a normal outcome, not a failure.",
17842
+ spend: "TWO BEATS \u2014 free preview FIRST, the real reveal only after the user confirms. Beat 1: call leadbay_enrich_titles with the drafted lead's id + lensId and NO titles / NO confirm / NO email / NO phone. That returns mode:'discover' \u2014 the FREE list of job titles at that company. Say plainly that nothing has been spent yet. Beat 2: name the title the draft is addressed to, tell them BEFORE they decide what it costs (one credit per contact revealed \u2014 here that is ONE contact, one credit), and ask them to confirm. Only then call leadbay_enrich_titles AGAIN with leadIds: [<that lead id>] \u2014 ALWAYS the array, even for a single lead: `leadId` singular is not a key this tool reads, so it is dropped and the paid call falls back to the default wishlist selection, charging for the whole batch \u2014 plus the chosen title, confirm:true and email:true. Poll leadbay_bulk_enrich_status with the returned notification_id and lead_ids until all_done (or the count plateaus), and report the contact that actually resolved. NEVER launch the reveal without an explicit confirm: silence is not consent, and neither is 'they clicked the gate'. If they decline, keep the draft and the title and move on \u2014 that is a normal outcome, not a failure.",
17371
17843
  quota_note: "After the reveal, close the loop on gate 1 in one line: one credit per contact revealed, so this cost one. Then say the thing that makes it land \u2014 the draft from gate 3 now has a real person and a real address to go to. Re-check leadbay_account_status if you want to show the moved windows. This is where gate 1's numbers stop being abstract: they just watched them move, and got something for it. Keep it to a line; no pricing pitch."
17372
17844
  }
17373
17845
  ],
@@ -18415,7 +18887,7 @@ var bulkQualifyLeads = {
18415
18887
  },
18416
18888
  wait_for_completion: {
18417
18889
  type: "boolean",
18418
- description: "When false, launch qualification and return `{status:'running', qualify_id}` immediately. Poll leadbay_qualify_status. Default is true for 0.6.x backwards compatibility."
18890
+ description: "When false, launch qualification and return `{status:'running', notification_id, lead_ids, lens_id}` immediately. Poll leadbay_qualify_status with them. Default is true for 0.6.x backwards compatibility."
18419
18891
  }
18420
18892
  },
18421
18893
  additionalProperties: false
@@ -18432,13 +18904,15 @@ var bulkQualifyLeads = {
18432
18904
  type: "string",
18433
18905
  description: "`running` when wait_for_completion=false; absent on the legacy blocking result."
18434
18906
  },
18435
- handle_id: { type: "string", description: "Alias of qualify_id for handle-oriented callers." },
18436
- qualify_id: { type: "string", description: "UUIDv4 to poll via leadbay_qualify_status." },
18907
+ notification_id: {
18908
+ type: ["string", "null"],
18909
+ description: "The backend's job id. Carry it to leadbay_qualify_status."
18910
+ },
18437
18911
  lead_ids: { type: "array", items: { type: "string" } },
18438
18912
  launched_count: { type: "number" },
18439
18913
  still_running: {
18440
18914
  type: "array",
18441
- description: "Leads launched but whose qualification did not complete within budget. Re-poll via leadbay_qualify_status with the bulk_id (when present).",
18915
+ description: "Leads launched but whose qualification did not complete within budget. Re-poll via leadbay_qualify_status with notification_id (when present) or lead_ids + lens_id.",
18442
18916
  items: { type: "object" }
18443
18917
  },
18444
18918
  failed: {
@@ -18472,8 +18946,7 @@ var bulkQualifyLeads = {
18472
18946
  {
18473
18947
  required: [
18474
18948
  "status",
18475
- "handle_id",
18476
- "qualify_id",
18949
+ "notification_id",
18477
18950
  "lead_ids",
18478
18951
  "launched_count",
18479
18952
  "failed",
@@ -18533,45 +19006,45 @@ var bulkQualifyLeads = {
18533
19006
  };
18534
19007
  }
18535
19008
  if (!waitForCompletion) {
18536
- if (!ctx?.bulkTracker) {
18537
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_bulk_qualify_leads wait_for_completion=false needs a BulkTracker so qualify_id survives restart.", "");
19009
+ const fingerprint = launchFingerprint(["qualify", candidates, lensId]);
19010
+ const claim = beginLaunch(fingerprint);
19011
+ if (claim.state === "in_flight") {
19012
+ throw client.makeError("QUALIFY_LAUNCH_IN_FLIGHT", `An identical qualification was started ${claim.seconds_since}s ago and has not returned its job id yet`, "Nothing was launched twice. Call leadbay_bulk_qualify_leads again with the same arguments in a few seconds to receive the notification_id.", "");
18538
19013
  }
18539
- const reservation = await ctx.bulkTracker.findOrCreatePendingQualify({
18540
- lead_ids: candidates,
18541
- import_ids: [],
18542
- lens_id: lensId,
18543
- mapping_fingerprint: "bulk_qualify_leads",
18544
- per_lead_budget_ms: perLeadBudget,
18545
- total_budget_ms: totalBudget
18546
- });
18547
- let launchedCount = 0;
18548
- let notificationId = null;
18549
- let quotaExceeded2 = false;
18550
- let failed2 = [];
18551
- if (!reservation.reused) {
18552
- const launch = await launchBulkQualify(client, candidates, ctx);
18553
- quotaExceeded2 = launch.quotaExceeded;
18554
- notificationId = launch.resp?.notification_id ?? null;
18555
- const queuedIds = launch.resp?.queued_ids ?? [];
18556
- const skippedIds = launch.resp?.skipped_ids ?? [];
18557
- launchedCount = queuedIds.length;
18558
- const seen = /* @__PURE__ */ new Set([...queuedIds, ...skippedIds]);
18559
- failed2 = candidates.filter((id) => !seen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
18560
- if (queuedIds.length > 0 || quotaExceeded2 || skippedIds.length > 0 || failed2.length === candidates.length) {
18561
- await ctx.bulkTracker.markLaunched(reservation.record.bulk_id, notificationId);
18562
- }
18563
- } else {
18564
- notificationId = reservation.record.notification_id ?? null;
18565
- launchedCount = reservation.record.lead_ids.length;
19014
+ const already = claim.state === "settled" ? claim.record : void 0;
19015
+ if (already) {
19016
+ return {
19017
+ status: "running",
19018
+ lead_ids: candidates,
19019
+ launched_count: candidates.length,
19020
+ failed: [],
19021
+ quota_exceeded: false,
19022
+ lens_id: lensId,
19023
+ notification_id: already.notification_id,
19024
+ reused: true,
19025
+ seconds_since_original_launch: already.seconds_since,
19026
+ _meta: { region: client.region }
19027
+ };
18566
19028
  }
19029
+ let launch;
19030
+ try {
19031
+ launch = await launchBulkQualify(client, candidates, ctx);
19032
+ } catch (err) {
19033
+ abandonLaunch(fingerprint);
19034
+ throw err;
19035
+ }
19036
+ const notificationId = launch.resp?.notification_id ?? null;
19037
+ const queuedIds = launch.resp?.queued_ids ?? [];
19038
+ const skippedIds = launch.resp?.skipped_ids ?? [];
19039
+ const seen = /* @__PURE__ */ new Set([...queuedIds, ...skippedIds]);
19040
+ const failed2 = candidates.filter((id) => !seen.has(id)).map((id) => ({ lead_id: id, error: "not_queued" }));
19041
+ rememberLaunch(fingerprint, notificationId);
18567
19042
  const out = {
18568
19043
  status: "running",
18569
- handle_id: reservation.record.bulk_id,
18570
- qualify_id: reservation.record.bulk_id,
18571
19044
  lead_ids: candidates,
18572
- launched_count: launchedCount,
19045
+ launched_count: queuedIds.length,
18573
19046
  failed: failed2,
18574
- quota_exceeded: quotaExceeded2,
19047
+ quota_exceeded: launch.quotaExceeded,
18575
19048
  lens_id: lensId,
18576
19049
  notification_id: notificationId,
18577
19050
  _meta: { region: client.region }
@@ -19145,7 +19618,7 @@ var importAndQualify = {
19145
19618
  },
19146
19619
  total_budget_ms: {
19147
19620
  type: "number",
19148
- description: `Total wall-clock budget across import + qualify in ms (default ${DEFAULT_TOTAL_BUDGET_MS3}). When exhausted, the response returns qualify_id for resume via leadbay_qualify_status.`
19621
+ description: `Total wall-clock budget across import + qualify in ms (default ${DEFAULT_TOTAL_BUDGET_MS3}). When exhausted, the response returns lead_ids + lens_id for resume via leadbay_qualify_status.`
19149
19622
  },
19150
19623
  per_phase_budget_ms: {
19151
19624
  type: "number",
@@ -19153,7 +19626,7 @@ var importAndQualify = {
19153
19626
  },
19154
19627
  wait_for_completion: {
19155
19628
  type: "boolean",
19156
- description: "When false, enqueue the import phase and return `{kind:'result', status:'running', handle_id}` immediately. Poll leadbay_import_status. Default is true for 0.6.x backwards compatibility."
19629
+ description: "When false, upload the rows and return `{kind:'result', status:'running', import_ids}` immediately. Poll leadbay_import_status({importIds, dry_run}); the qualify phase does NOT run on this path \u2014 call leadbay_bulk_qualify_leads on the imported leads yourself. Default is true."
19157
19630
  },
19158
19631
  lensId: {
19159
19632
  type: "number",
@@ -19176,7 +19649,7 @@ var importAndQualify = {
19176
19649
  },
19177
19650
  outputSchema: {
19178
19651
  type: "object",
19179
- description: "Two return shapes: kind:'preview' (when dry_run='preview') with mapping hints; kind:'result' (default) with imported + qualified leads + qualify_id handle.",
19652
+ description: "Two return shapes: kind:'preview' (when dry_run='preview') with mapping hints; kind:'result' (default) with imported + qualified leads + lead_ids/lens_id to resume via leadbay_qualify_status.",
19180
19653
  properties: {
19181
19654
  kind: {
19182
19655
  type: "string",
@@ -19186,10 +19659,6 @@ var importAndQualify = {
19186
19659
  type: "string",
19187
19660
  description: "`running` when wait_for_completion=false."
19188
19661
  },
19189
- handle_id: {
19190
- type: "string",
19191
- description: "Import handle to pass to leadbay_import_status when wait_for_completion=false."
19192
- },
19193
19662
  timed_out: {
19194
19663
  type: "boolean",
19195
19664
  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."
@@ -19234,10 +19703,12 @@ var importAndQualify = {
19234
19703
  type: "object",
19235
19704
  description: "Adaptive budgets the composite selected (when caller didn't override): {per_lead_budget_ms, total_budget_ms, per_phase_budget_ms, wall_clock_estimate_ms, strategy}."
19236
19705
  },
19237
- qualify_id: {
19238
- type: ["string", "null"],
19239
- description: "UUIDv4 handle for polling via leadbay_qualify_status. Null when no leads were qualified."
19706
+ lead_ids: {
19707
+ type: "array",
19708
+ description: "Leads the qualify phase covers. Pass to leadbay_qualify_status for per-lead detail.",
19709
+ items: { type: "string" }
19240
19710
  },
19711
+ lens_id: { type: "number", description: "Lens the qualification ran against." },
19241
19712
  import_ids: {
19242
19713
  type: "array",
19243
19714
  description: "Backend file-import handles (one per chunk).",
@@ -19260,7 +19731,7 @@ var importAndQualify = {
19260
19731
  },
19261
19732
  still_running: {
19262
19733
  type: "array",
19263
- description: "Leads still being qualified at deadline; agent calls leadbay_qualify_status with qualify_id.",
19734
+ description: "Leads still being qualified at deadline; agent calls leadbay_qualify_status with lead_ids + lens_id.",
19264
19735
  items: { type: "object" }
19265
19736
  },
19266
19737
  failed: {
@@ -19281,10 +19752,13 @@ var importAndQualify = {
19281
19752
  },
19282
19753
  reused: {
19283
19754
  type: "boolean",
19284
- description: "True when an identical qualify_id was launched within the idempotency window."
19755
+ description: "True when an identical launch was reused within the idempotency window."
19285
19756
  },
19286
19757
  seconds_since_original: { type: "number" },
19287
- cancelled: { type: "boolean", description: "True when ctx.signal aborted mid-flight." },
19758
+ cancelled: {
19759
+ type: "boolean",
19760
+ description: "True when the HOST cancelled the call (a user Cancel, or the host's own request timeout); a budget timeout reports `budget_exhausted` instead. The import and the qualifications already launched keep running on Leadbay, except a chunk cancelled before its mappings were committed, which can read `running` without ever progressing \u2014 if the counts stop moving, say so rather than polling on. Poll leadbay_import_status with the `import_ids` values passed as `importIds`, and leadbay_qualify_status with lead_ids + lens_id. `still_running` can also hold leads whose qualification was never launched; those need a fresh qualification, which qualify_status cannot start."
19761
+ },
19288
19762
  budget_exhausted: { type: "boolean", description: "True when total_budget_ms hit before all leads finished." },
19289
19763
  quota_blocked: { type: "boolean", description: "True when quota was exhausted before launching all leads." },
19290
19764
  region: { type: "string" },
@@ -19306,9 +19780,6 @@ var importAndQualify = {
19306
19780
  if (params.dry_run === "preview") {
19307
19781
  return await runPreview(client, params, ctx, perPhaseBudget, totalBudget);
19308
19782
  }
19309
- if (!ctx?.bulkTracker) {
19310
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_and_qualify needs a BulkTracker (qualify_id persistence). Upgrade to @leadbay/mcp \u22650.5.0 or set LEADBAY_BULK_STORE_ALLOW_MEMORY=1.", "");
19311
- }
19312
19783
  if (params.wait_for_completion === false) {
19313
19784
  const queued = await importLeads.execute(client, {
19314
19785
  domains: params.domains,
@@ -19323,7 +19794,8 @@ var importAndQualify = {
19323
19794
  return {
19324
19795
  kind: "result",
19325
19796
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
19326
- qualify_id: null,
19797
+ lead_ids: [],
19798
+ lens_id: 0,
19327
19799
  import_ids: queued.importIds,
19328
19800
  notification_ids: queued.notification_ids ?? [],
19329
19801
  imported: queued.leads.map((l) => ({
@@ -19346,9 +19818,9 @@ var importAndQualify = {
19346
19818
  return {
19347
19819
  kind: "result",
19348
19820
  status: "running",
19349
- ...queued.handle_id ? { handle_id: queued.handle_id } : {},
19350
19821
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
19351
- qualify_id: null,
19822
+ lead_ids: [],
19823
+ lens_id: 0,
19352
19824
  import_ids: queued.importIds,
19353
19825
  notification_ids: queued.notification_ids ?? [],
19354
19826
  imported: [],
@@ -19381,7 +19853,6 @@ var importAndQualify = {
19381
19853
  return {
19382
19854
  kind: "result",
19383
19855
  status: "running",
19384
- ...importResultRaw.handle_id ? { handle_id: importResultRaw.handle_id } : {},
19385
19856
  // Everything the rendering contract keys off has to survive the
19386
19857
  // wrapper. Without `timed_out` the agent can't tell this from a
19387
19858
  // deliberate async launch; without `rows_pending_upload` a >100-row
@@ -19393,7 +19864,8 @@ var importAndQualify = {
19393
19864
  ...importResultRaw.dry_run ? { dry_run: true } : {},
19394
19865
  ...importResultRaw.row_ids ? { row_ids: importResultRaw.row_ids } : {},
19395
19866
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
19396
- qualify_id: null,
19867
+ lead_ids: [],
19868
+ lens_id: 0,
19397
19869
  import_ids: importResultRaw.importIds,
19398
19870
  notification_ids: importResultRaw.notification_ids ?? [],
19399
19871
  imported: [],
@@ -19414,7 +19886,8 @@ var importAndQualify = {
19414
19886
  kind: "result",
19415
19887
  ...params.dry_run === true ? { dry_run: true } : {},
19416
19888
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
19417
- qualify_id: null,
19889
+ lead_ids: [],
19890
+ lens_id: 0,
19418
19891
  import_ids: importResult.importIds,
19419
19892
  notification_ids: importResult.notification_ids ?? [],
19420
19893
  imported: [],
@@ -19452,7 +19925,8 @@ var importAndQualify = {
19452
19925
  kind: "result",
19453
19926
  ...params.dry_run === true ? { dry_run: true } : {},
19454
19927
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
19455
- qualify_id: null,
19928
+ lead_ids: [],
19929
+ lens_id: 0,
19456
19930
  import_ids: importResult.importIds,
19457
19931
  notification_ids: importResult.notification_ids ?? [],
19458
19932
  imported,
@@ -19498,29 +19972,17 @@ var importAndQualify = {
19498
19972
  // targets do NOT collide on the same qualify_id.
19499
19973
  buildFingerprintInput(params.mappings)
19500
19974
  );
19501
- const reservation = await ctx.bulkTracker.findOrCreatePendingQualify({
19502
- lead_ids: leadIds,
19503
- import_ids: importResult.importIds,
19504
- lens_id: lensId,
19505
- mapping_fingerprint: mappingFp,
19506
- per_lead_budget_ms: perLeadBudget,
19507
- total_budget_ms: totalBudget
19508
- });
19509
- if (reservation.reused) {
19510
- ctx?.logger?.info?.(`import_and_qualify: reusing qualify_id=${reservation.record.bulk_id} (seconds_since_original=${reservation.seconds_since_original})`);
19511
- }
19512
- let launchMarked = false;
19513
- for (const attempt of [1, 2]) {
19514
- try {
19515
- await ctx.bulkTracker.markLaunched(reservation.record.bulk_id);
19516
- launchMarked = true;
19517
- break;
19518
- } catch (err) {
19519
- ctx?.logger?.warn?.(`import_and_qualify: markLaunched attempt ${attempt} failed: ${err?.message ?? err}`);
19520
- }
19521
- }
19522
- if (!launchMarked) {
19523
- ctx?.logger?.warn?.(`import_and_qualify: markLaunched failed twice \u2014 qualify_status may BULK_PENDING-trap immediate retrieval; agent should poll, not relaunch`);
19975
+ const qualifyFingerprint = launchFingerprint([
19976
+ "import_qualify",
19977
+ leadIds,
19978
+ importResult.importIds,
19979
+ lensId,
19980
+ mappingFp
19981
+ ]);
19982
+ const qualifyClaim = beginLaunch(qualifyFingerprint);
19983
+ const alreadyQualified = qualifyClaim.state === "settled" ? qualifyClaim.record : void 0;
19984
+ if (alreadyQualified) {
19985
+ ctx?.logger?.info?.(`import_and_qualify: identical qualify ran ${alreadyQualified.seconds_since}s ago; re-running the fan-out to report current per-lead state (already-qualified leads are skipped inside it)`);
19524
19986
  }
19525
19987
  let questionOrder = void 0;
19526
19988
  try {
@@ -19534,22 +19996,24 @@ var importAndQualify = {
19534
19996
  total: 3,
19535
19997
  message: `Qualifying ${leadIds.length} lead${leadIds.length === 1 ? "" : "s"} (phase 3/3)`
19536
19998
  });
19537
- const fanOut = await fanOutWebFetchAndPoll(client, leadIds, {
19538
- perLeadBudgetMs: perLeadBudget,
19539
- totalDeadlineMs: totalDeadline,
19540
- signal,
19541
- ctx,
19542
- skipAlreadyQualifiedLensId: lensId,
19543
- skipAlreadyQualifiedLaunch: skipAlreadyQualified,
19544
- ...questionOrder ? { questionOrder } : {}
19545
- });
19546
- if (fanOut.cancelled) {
19547
- try {
19548
- await ctx.bulkTracker.markCancelled(reservation.record.bulk_id);
19549
- } catch (err) {
19550
- ctx?.logger?.warn?.(`import_and_qualify: tracker.markCancelled failed: ${err?.message ?? err}`);
19551
- }
19999
+ let fanOut;
20000
+ try {
20001
+ fanOut = await fanOutWebFetchAndPoll(client, leadIds, {
20002
+ perLeadBudgetMs: perLeadBudget,
20003
+ totalDeadlineMs: totalDeadline,
20004
+ signal,
20005
+ ctx,
20006
+ skipAlreadyQualifiedLensId: lensId,
20007
+ skipAlreadyQualifiedLaunch: skipAlreadyQualified,
20008
+ ...questionOrder ? { questionOrder } : {}
20009
+ });
20010
+ } catch (err) {
20011
+ if (!alreadyQualified)
20012
+ abandonLaunch(qualifyFingerprint);
20013
+ throw err;
19552
20014
  }
20015
+ if (!alreadyQualified)
20016
+ rememberLaunch(qualifyFingerprint, null);
19553
20017
  const qualified = fanOut.results.filter((r) => !r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
19554
20018
  const notInLensSet = new Set(fanOut.not_in_lens);
19555
20019
  const stillRunningIds = new Set([
@@ -19563,7 +20027,8 @@ var importAndQualify = {
19563
20027
  return {
19564
20028
  kind: "result",
19565
20029
  ...chosenBudgets ? { chosen_budgets: chosenBudgets } : {},
19566
- qualify_id: reservation.record.bulk_id,
20030
+ lead_ids: leadIds,
20031
+ lens_id: lensId,
19567
20032
  import_ids: importResult.importIds,
19568
20033
  notification_ids: importResult.notification_ids ?? [],
19569
20034
  imported,
@@ -19574,9 +20039,9 @@ var importAndQualify = {
19574
20039
  quota_exceeded: fanOut.quota_exceeded,
19575
20040
  skipped_already_qualified,
19576
20041
  not_in_lens: fanOut.not_in_lens,
19577
- ...reservation.reused ? {
20042
+ ...alreadyQualified ? {
19578
20043
  reused: true,
19579
- seconds_since_original: reservation.seconds_since_original
20044
+ seconds_since_original: alreadyQualified.seconds_since
19580
20045
  } : {},
19581
20046
  ...fanOut.cancelled ? { cancelled: true } : {},
19582
20047
  ...budgetExhausted ? { budget_exhausted: true } : {},
@@ -19699,19 +20164,6 @@ async function runPreview(client, params, ctx, perPhaseBudget, _totalBudget) {
19699
20164
  };
19700
20165
  }
19701
20166
 
19702
- // ../core/dist/jobs/bulk-store.js
19703
- import { mkdir as mkdirAsync, lstat, open as fsOpen, readFile, rename, stat, unlink } from "fs/promises";
19704
- import { constants as fsConstants } from "fs";
19705
- import { dirname, resolve as resolvePath } from "path";
19706
- import { homedir, platform } from "os";
19707
- import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
19708
- var DEFAULT_IDEMPOTENCY_WINDOW_MS = 5 * 60 * 1e3;
19709
- var TTL_MS = 30 * 24 * 60 * 60 * 1e3;
19710
- var UUIDV4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
19711
- function isValidBulkId(v) {
19712
- return typeof v === "string" && UUIDV4_RE.test(v);
19713
- }
19714
-
19715
20167
  // ../core/dist/composite/import-status.js
19716
20168
  function summarizeImports(imports, dryRun) {
19717
20169
  let recordsTotal = 0;
@@ -19828,10 +20280,6 @@ var importStatus = {
19828
20280
  inputSchema: {
19829
20281
  type: "object",
19830
20282
  properties: {
19831
- handle_id: {
19832
- type: "string",
19833
- description: "UUIDv4 handle returned by leadbay_import_leads when wait_for_completion=false."
19834
- },
19835
20283
  importIds: {
19836
20284
  type: "array",
19837
20285
  description: "Backend file-import ids to inspect directly \u2014 from a completed import's `importIds`, or from a `{status:'running', timed_out:true}` result.",
@@ -19848,12 +20296,11 @@ var importStatus = {
19848
20296
  type: "object",
19849
20297
  properties: {
19850
20298
  status: { type: "string", description: "running, complete, or failed." },
19851
- handle_id: { type: "string" },
19852
20299
  importIds: { type: "array", items: { type: "string" } },
19853
20300
  progress: { type: "object" },
19854
20301
  result: {
19855
20302
  type: "object",
19856
- 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."
20303
+ description: "Final import result: {leads, not_imported, importIds, still_settling?}. Present when the importIds[] path finds every import complete and reconciles the wizard's records."
19857
20304
  },
19858
20305
  error: { type: "string" },
19859
20306
  dry_run: {
@@ -19866,99 +20313,20 @@ var importStatus = {
19866
20313
  required: ["status", "importIds", "progress", "region", "_meta"]
19867
20314
  },
19868
20315
  execute: async (client, params, ctx) => {
19869
- let handleId = params.handle_id;
19870
20316
  let importIds = params.importIds ?? [];
19871
- let handleDryRun = params.dry_run;
19872
- if (handleId) {
19873
- if (!isValidBulkId(handleId)) {
19874
- throw client.makeError("BULK_INVALID_ID", "handle_id is not a valid UUIDv4", "Pass the handle_id returned by leadbay_import_leads verbatim.", "");
19875
- }
19876
- if (!ctx?.bulkTracker) {
19877
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_status needs a BulkTracker to resolve handle_id. Pass importIds[] directly as a fallback.", "");
19878
- }
19879
- const record = await ctx.bulkTracker.getImport(handleId);
19880
- if (!record) {
19881
- const any = await ctx.bulkTracker.get(handleId);
19882
- if (any && any.kind !== "import") {
19883
- throw client.makeError("BULK_WRONG_KIND", "This handle was not created by leadbay_import_leads", "Use leadbay_qualify_status for qualify ids or leadbay_bulk_enrich_status for enrich ids.", "");
19884
- }
19885
- 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.", "");
19886
- }
19887
- importIds = record.import_ids;
19888
- handleDryRun = record.dry_run ?? handleDryRun;
19889
- if (record.status === "complete" && record.result) {
19890
- return {
19891
- status: "complete",
19892
- handle_id: handleId,
19893
- importIds,
19894
- progress: record.progress ?? {
19895
- phase: "complete",
19896
- records_processed: record.records_total,
19897
- records_total: record.records_total
19898
- },
19899
- result: record.result,
19900
- region: client.region,
19901
- _meta: client.lastMeta ?? {
19902
- region: client.region,
19903
- endpoint: "bulk-store",
19904
- latency_ms: null,
19905
- retry_after: null
19906
- }
19907
- };
19908
- }
19909
- if (record.status === "failed") {
19910
- return {
19911
- status: "failed",
19912
- handle_id: handleId,
19913
- importIds,
19914
- progress: record.progress ?? {
19915
- phase: "failed",
19916
- records_processed: 0,
19917
- records_total: record.records_total
19918
- },
19919
- error: record.error ?? "import failed",
19920
- region: client.region,
19921
- _meta: client.lastMeta ?? {
19922
- region: client.region,
19923
- endpoint: "bulk-store",
19924
- latency_ms: null,
19925
- retry_after: null
19926
- }
19927
- };
19928
- }
19929
- if (importIds.length === 0) {
19930
- return {
19931
- status: "running",
19932
- handle_id: handleId,
19933
- importIds,
19934
- progress: record.progress ?? {
19935
- phase: "queued",
19936
- records_processed: 0,
19937
- records_total: record.records_total
19938
- },
19939
- region: client.region,
19940
- _meta: client.lastMeta ?? {
19941
- region: client.region,
19942
- endpoint: "bulk-store",
19943
- latency_ms: null,
19944
- retry_after: null
19945
- }
19946
- };
19947
- }
19948
- }
19949
20317
  importIds = [...new Set(importIds)];
19950
20318
  if (importIds.length === 0) {
19951
- 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.", "");
20319
+ throw client.makeError("IMPORT_STATUS_INPUT_REQUIRED", "Pass importIds[]", "Call leadbay_import_leads with wait_for_completion=false first, then pass back the importIds it returned.", "");
19952
20320
  }
19953
20321
  const imports = await Promise.all(importIds.map((id) => client.request("GET", `/imports/${id}`)));
19954
- const progress = summarizeImports(imports, handleDryRun);
20322
+ const progress = summarizeImports(imports, params.dry_run);
19955
20323
  const failed = imports.find((i) => i.pre_processing?.error || i.processing?.error);
19956
20324
  const complete = imports.every((i) => {
19957
20325
  if (i.pre_processing?.error || i.processing?.error)
19958
20326
  return false;
19959
- if (handleDryRun === true)
20327
+ if (params.dry_run === true)
19960
20328
  return Boolean(i.pre_processing?.finished);
19961
- if (handleDryRun === false)
20329
+ if (params.dry_run === false)
19962
20330
  return Boolean(i.processing?.finished);
19963
20331
  return Boolean(i.processing?.finished || i.pre_processing?.finished && !i.processing);
19964
20332
  });
@@ -19966,7 +20334,7 @@ var importStatus = {
19966
20334
  const commitError = commitFailureFor(importIds);
19967
20335
  let notReady = false;
19968
20336
  const declaredTotal = imports.reduce((n, i) => n + Number(i.total_records ?? 0), 0);
19969
- if (!failed && complete && handleDryRun !== true && importIds.length > 0) {
20337
+ if (!failed && complete && params.dry_run !== true && importIds.length > 0) {
19970
20338
  try {
19971
20339
  reconciled = await fetchReconciledRecords(client, importIds, declaredTotal, ctx);
19972
20340
  } catch (err) {
@@ -19981,9 +20349,8 @@ var importStatus = {
19981
20349
  const settled = complete && !notReady;
19982
20350
  return {
19983
20351
  status: failed || commitError ? "failed" : settled ? "complete" : "running",
19984
- ...handleId ? { handle_id: handleId } : {},
19985
20352
  importIds,
19986
- ...handleDryRun === true ? { dry_run: true } : {},
20353
+ ...params.dry_run === true ? { dry_run: true } : {},
19987
20354
  progress: notReady ? { ...progress, phase: "committing" } : progress,
19988
20355
  ...reconciled ? {
19989
20356
  result: {
@@ -20007,15 +20374,30 @@ var importStatus = {
20007
20374
  }
20008
20375
  };
20009
20376
 
20010
- // ../core/dist/composite/qualify-status.js
20011
- async function readNotification(client, notificationId) {
20012
- try {
20013
- const page = await client.listNotifications({ archived: false, count: 50 });
20014
- return page.items.find((n) => n.id === notificationId) ?? null;
20015
- } catch {
20016
- return null;
20377
+ // ../core/dist/notifications/read-by-id.js
20378
+ var PAGE_SIZE2 = 50;
20379
+ var MAX_PAGES = 4;
20380
+ async function readNotificationById(client, notificationId) {
20381
+ for (const archived of [false, true]) {
20382
+ for (let page = 0; page < MAX_PAGES; page += 1) {
20383
+ let res;
20384
+ try {
20385
+ res = await client.listNotifications({ archived, page, count: PAGE_SIZE2 });
20386
+ } catch {
20387
+ return null;
20388
+ }
20389
+ const hit = res.items.find((n) => n.id === notificationId);
20390
+ if (hit)
20391
+ return hit;
20392
+ const pages = res.pagination?.pages ?? 1;
20393
+ if (page + 1 >= pages)
20394
+ break;
20395
+ }
20017
20396
  }
20397
+ return null;
20018
20398
  }
20399
+
20400
+ // ../core/dist/composite/qualify-status.js
20019
20401
  var qualifyStatus = {
20020
20402
  name: "leadbay_qualify_status",
20021
20403
  annotations: {
@@ -20029,18 +20411,30 @@ var qualifyStatus = {
20029
20411
  inputSchema: {
20030
20412
  type: "object",
20031
20413
  properties: {
20032
- qualify_id: {
20414
+ notification_id: {
20033
20415
  type: "string",
20034
- description: "UUIDv4 returned by leadbay_import_and_qualify when at least one lead was still running."
20416
+ description: "The `notification_id` returned by leadbay_import_and_qualify / leadbay_bulk_qualify_leads. Answers progress in ONE call."
20417
+ },
20418
+ lead_ids: {
20419
+ type: "array",
20420
+ description: "The `lead_ids` the launch returned. Supply them for per-lead detail (which settled, which are still running). Progress alone needs only notification_id.",
20421
+ items: { type: "string" }
20422
+ },
20423
+ lens_id: {
20424
+ type: "number",
20425
+ description: "The `lens_id` the launch returned. Used to flag leads no longer in the lens."
20035
20426
  }
20036
20427
  },
20037
- required: ["qualify_id"],
20428
+ anyOf: [{ required: ["notification_id"] }, { required: ["lead_ids"] }],
20038
20429
  additionalProperties: false
20039
20430
  },
20040
20431
  outputSchema: {
20041
20432
  type: "object",
20042
20433
  properties: {
20043
- qualify_id: { type: "string", description: "Echoed UUIDv4 handle." },
20434
+ notification_id: {
20435
+ type: ["string", "null"],
20436
+ description: "The backend job id this status is for; pass it back to poll again."
20437
+ },
20044
20438
  launched_at: { type: "string", description: "ISO timestamp of original launch." },
20045
20439
  status: { type: "string", description: "'launched' on success (other states surface as error envelopes)." },
20046
20440
  import_ids: {
@@ -20051,7 +20445,7 @@ var qualifyStatus = {
20051
20445
  lens_id: { type: "number", description: "Lens id the qualification ran against." },
20052
20446
  lead_ids: {
20053
20447
  type: "array",
20054
- description: "Lead UUIDs covered by this qualify_id (echoed from launch).",
20448
+ description: "Lead UUIDs covered by this status (echoed from launch).",
20055
20449
  items: { type: "string" }
20056
20450
  },
20057
20451
  qualified: {
@@ -20086,7 +20480,7 @@ var qualifyStatus = {
20086
20480
  _meta: { type: "object" }
20087
20481
  },
20088
20482
  required: [
20089
- "qualify_id",
20483
+ "notification_id",
20090
20484
  "status",
20091
20485
  "import_ids",
20092
20486
  "lens_id",
@@ -20100,29 +20494,55 @@ var qualifyStatus = {
20100
20494
  ]
20101
20495
  },
20102
20496
  execute: async (client, params, ctx) => {
20103
- if (!isValidBulkId(params.qualify_id)) {
20104
- throw client.makeError("BULK_INVALID_ID", "qualify_id is not a valid UUIDv4", "Pass the qualify_id returned by leadbay_import_and_qualify verbatim.", "");
20105
- }
20106
- if (!ctx?.bulkTracker) {
20107
- throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_qualify_status needs a BulkTracker. Upgrade to @leadbay/mcp \u22650.5.0 or set LEADBAY_BULK_STORE_ALLOW_MEMORY=1.", "");
20497
+ const notifId = params.notification_id ?? null;
20498
+ const leadIds = params.lead_ids ?? [];
20499
+ if (!notifId && leadIds.length === 0) {
20500
+ throw client.makeError("QUALIFY_STATUS_INPUT_REQUIRED", "Pass notification_id (for progress) and/or lead_ids (for per-lead detail)", "Both are in the launch response from leadbay_bulk_qualify_leads / leadbay_import_and_qualify. Re-read that result and pass them back.", "");
20108
20501
  }
20109
- const record = await ctx.bulkTracker.getQualify(params.qualify_id);
20110
- if (!record) {
20111
- const any = await ctx.bulkTracker.get(params.qualify_id);
20112
- if (any && any.kind !== "qualify") {
20113
- const hint = any.kind === "import" ? "Call leadbay_import_status with this id instead." : "Call leadbay_bulk_enrich_status with this id instead.";
20114
- throw client.makeError("BULK_WRONG_KIND", `This bulk_id was created by ${any.kind}, not leadbay_import_and_qualify`, hint, "");
20502
+ let bulkProgress = null;
20503
+ let inProgressFlag = null;
20504
+ let launchedAt = null;
20505
+ if (notifId) {
20506
+ const n = await readNotificationById(client, notifId);
20507
+ if (!n && leadIds.length === 0) {
20508
+ throw client.makeError("QUALIFY_JOB_NOT_FOUND", "No job for that notification_id", "The lookup scans this user's recent notifications (archived included); a job behind many newer ones will not be found. Re-call with the `lead_ids` + `lens_id` the launch returned \u2014 that answers per lead without the notification.", "");
20509
+ }
20510
+ if (n && inferKind(n) !== "bulk_qualify") {
20511
+ const kind = inferKind(n);
20512
+ throw client.makeError("QUALIFY_JOB_WRONG_KIND", `That notification_id is a ${kind === "bulk_enrich" ? "contact enrichment" : kind === "import" ? "file import" : "non-bulk"} notification, not a lead qualification`, kind === "bulk_enrich" ? "Poll it with leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone}) instead \u2014 carry the titles and channel the enrichment was launched with, or a contact enriched earlier counts as done and all_done flips true before anything landed." : kind === "import" ? "Poll it with leadbay_import_status({importIds}) instead." : "Pass the notification_id returned by leadbay_bulk_qualify_leads.", "");
20513
+ }
20514
+ if (n) {
20515
+ bulkProgress = n.bulk_progress;
20516
+ inProgressFlag = n.in_progress;
20517
+ launchedAt = n.created_at;
20115
20518
  }
20116
- throw client.makeError("BULK_NOT_FOUND", "No qualify record for that qualify_id", "It may have expired (30-day TTL) or the MCP process was restarted without persistence. Re-launch via leadbay_import_and_qualify.", "");
20117
- }
20118
- if (record.status === "pending") {
20119
- throw client.makeError("BULK_PENDING", "Qualify record is in 'pending' state \u2014 the launch may be in flight or crashed before launch ack", "Retry leadbay_qualify_status in a few seconds. If it persists >60s, relaunch via leadbay_import_and_qualify.", "");
20120
- }
20121
- if (record.status === "failed") {
20122
- throw client.makeError("BULK_LAUNCH_FAILED", "The original import_and_qualify launch failed; no qualifications were ordered", "Call leadbay_import_and_qualify again \u2014 the failed record won't block a fresh launch.", "");
20123
20519
  }
20124
- if (record.status === "cancelled") {
20125
- throw client.makeError("BULK_CANCELLED", "The qualify run was cancelled (ctx.signal aborted by the client mid-flight); no further qualifications are in flight", "Call leadbay_import_and_qualify again with the same input to relaunch \u2014 the cancelled record won't block a fresh launch.", "");
20520
+ if (leadIds.length === 0) {
20521
+ const out2 = {
20522
+ notification_id: notifId,
20523
+ launched_at: launchedAt ?? "",
20524
+ status: "launched",
20525
+ import_ids: [],
20526
+ lens_id: params.lens_id ?? 0,
20527
+ lead_ids: [],
20528
+ qualified: [],
20529
+ still_running: [],
20530
+ failed: [],
20531
+ not_in_lens: [],
20532
+ bulk_progress: bulkProgress,
20533
+ in_progress: inProgressFlag,
20534
+ region: client.region,
20535
+ _meta: client.lastMeta ?? {
20536
+ region: client.region,
20537
+ endpoint: "GET /notifications",
20538
+ latency_ms: null,
20539
+ retry_after: null
20540
+ }
20541
+ };
20542
+ if (bulkProgress && bulkProgress.quota_hit_count > 0) {
20543
+ out2.quota_hit_hint = "Some leads hit the AI-credits quota during qualification. Top up via leadbay_create_topup_link to clear the throttle immediately, or wait until the daily/weekly window resets.";
20544
+ }
20545
+ return out2;
20126
20546
  }
20127
20547
  ctx?.progress?.({
20128
20548
  progress: 1,
@@ -20138,20 +20558,20 @@ var qualifyStatus = {
20138
20558
  ctx?.progress?.({
20139
20559
  progress: 2,
20140
20560
  total: 3,
20141
- message: `Checking lens membership for ${record.lead_ids.length} lead${record.lead_ids.length === 1 ? "" : "s"}\u2026`
20561
+ message: `Checking lens membership for ${leadIds.length} lead${leadIds.length === 1 ? "" : "s"}\u2026`
20142
20562
  });
20143
20563
  let notInLensSet = /* @__PURE__ */ new Set();
20144
20564
  try {
20145
- const pre = await prequalifiedLeads(client, record.lead_ids, record.lens_id, ctx);
20565
+ const pre = await prequalifiedLeads(client, leadIds, params.lens_id ?? 0, ctx);
20146
20566
  notInLensSet = pre.not_in_lens;
20147
20567
  } catch {
20148
20568
  }
20149
20569
  ctx?.progress?.({
20150
20570
  progress: 3,
20151
20571
  total: 3,
20152
- message: `Refreshing qualification state for ${record.lead_ids.length} lead${record.lead_ids.length === 1 ? "" : "s"}\u2026`
20572
+ message: `Refreshing qualification state for ${leadIds.length} lead${leadIds.length === 1 ? "" : "s"}\u2026`
20153
20573
  });
20154
- const fresh = await refreshLeadStates(client, record.lead_ids, questionOrder);
20574
+ const fresh = await refreshLeadStates(client, leadIds, questionOrder);
20155
20575
  const failed = [];
20156
20576
  const qualified = [];
20157
20577
  const still_running = [];
@@ -20170,28 +20590,17 @@ var qualifyStatus = {
20170
20590
  const { _stillRunning, _failedCode, ...rest } = r;
20171
20591
  qualified.push(rest);
20172
20592
  }
20173
- let bulkProgress = null;
20174
- let inProgressFlag = null;
20175
- const notifId = record.notification_id ?? null;
20176
- if (notifId) {
20177
- const n = await readNotification(client, notifId);
20178
- if (n) {
20179
- bulkProgress = n.bulk_progress;
20180
- inProgressFlag = n.in_progress;
20181
- }
20182
- }
20183
20593
  const out = {
20184
- qualify_id: record.bulk_id,
20185
- launched_at: record.launched_at,
20186
- status: record.status === "complete" ? "launched" : record.status,
20187
- import_ids: record.import_ids,
20188
- lens_id: record.lens_id,
20189
- lead_ids: record.lead_ids,
20594
+ notification_id: notifId,
20595
+ launched_at: launchedAt ?? "",
20596
+ status: "launched",
20597
+ import_ids: [],
20598
+ lens_id: params.lens_id ?? 0,
20599
+ lead_ids: leadIds,
20190
20600
  qualified,
20191
20601
  still_running,
20192
20602
  failed,
20193
20603
  not_in_lens: [...notInLensSet],
20194
- notification_id: notifId,
20195
20604
  bulk_progress: bulkProgress,
20196
20605
  in_progress: inProgressFlag,
20197
20606
  region: client.region,
@@ -20202,10 +20611,6 @@ var qualifyStatus = {
20202
20611
  retry_after: null
20203
20612
  }
20204
20613
  };
20205
- if (record.per_lead_budget_ms !== void 0)
20206
- out.per_lead_budget_ms = record.per_lead_budget_ms;
20207
- if (record.total_budget_ms !== void 0)
20208
- out.total_budget_ms = record.total_budget_ms;
20209
20614
  if (bulkProgress && bulkProgress.quota_hit_count > 0) {
20210
20615
  out.quota_hit_hint = "Some leads hit the AI-credits quota during qualification. Top up via leadbay_create_topup_link to clear the throttle immediately, or wait until the daily/weekly window resets.";
20211
20616
  }
@@ -20217,171 +20622,81 @@ var qualifyStatus = {
20217
20622
  var DEFAULT_CANDIDATE_COUNT = 25;
20218
20623
  async function launchOnSelection(client, args, ctx) {
20219
20624
  const { leadIds, titles, email, phone, lensId, selectionSource, preview } = args;
20220
- {
20221
- {
20222
- const tracker = ctx?.bulkTracker;
20223
- let bulkRecord;
20224
- let bulkReused = false;
20225
- let bulkSecondsSinceOriginal;
20226
- if (tracker) {
20227
- const res = await tracker.findOrCreatePending({
20228
- lead_ids: leadIds,
20229
- titles,
20230
- email,
20231
- phone,
20232
- lens_id: lensId,
20233
- selection_source: selectionSource
20234
- });
20235
- bulkRecord = {
20236
- bulk_id: res.record.bulk_id,
20237
- launched_at: res.record.launched_at,
20238
- durability: res.record.durability
20239
- };
20240
- bulkReused = res.reused;
20241
- bulkSecondsSinceOriginal = res.seconds_since_original;
20242
- const AGED_PENDING_S = 60;
20243
- const agedPending = bulkReused && res.record.status === "pending" && (bulkSecondsSinceOriginal ?? 0) >= AGED_PENDING_S;
20244
- if (agedPending) {
20245
- const staleBulkId = res.record.bulk_id;
20246
- try {
20247
- await tracker.markFailed(staleBulkId);
20248
- } catch (e) {
20249
- ctx?.logger?.warn?.(`enrich_titles: markFailed on aged pending failed: ${e?.message ?? e}`);
20250
- }
20251
- bulkReused = false;
20252
- const fresh = await tracker.findOrCreatePending({
20253
- lead_ids: leadIds,
20254
- titles,
20255
- email,
20256
- phone,
20257
- lens_id: lensId,
20258
- selection_source: selectionSource
20259
- });
20260
- if (fresh.reused && fresh.record.bulk_id !== staleBulkId) {
20261
- return {
20262
- mode: "already_launched",
20263
- re_used: true,
20264
- bulk_id: fresh.record.bulk_id,
20265
- launched_at: fresh.record.launched_at,
20266
- durability: fresh.record.durability,
20267
- notification_id: fresh.record.notification_id ?? null,
20268
- seconds_since_original_launch: fresh.seconds_since_original ?? 0,
20269
- lead_ids: leadIds,
20270
- titles,
20271
- email,
20272
- phone,
20273
- preview,
20274
- message: "No new enrichment was ordered; quota not spent. A concurrent identical launch is already in flight. Unless the user asked NOT to wait, poll leadbay_bulk_enrich_status with this bulk_id for results (see next_action); if they asked not to wait, hand back the bulk_id.",
20275
- next_action: "Unless the user explicitly asked NOT to wait, poll leadbay_bulk_enrich_status({bulk_id}) until all_done \u2014 OR until overall_progress.done plateaus across spaced polls (~90s\u20132min; unresolvable contacts never flip). include_contacts=true on the read you report from, then report the resolved enrichment in this turn. If the user asked not to wait, hand back the bulk_id instead."
20276
- };
20277
- }
20278
- bulkRecord = {
20279
- bulk_id: fresh.record.bulk_id,
20280
- launched_at: fresh.record.launched_at,
20281
- durability: fresh.record.durability
20282
- };
20283
- } else if (bulkReused && res.record.status !== "failed") {
20284
- return {
20285
- mode: "already_launched",
20286
- re_used: true,
20287
- bulk_id: res.record.bulk_id,
20288
- launched_at: res.record.launched_at,
20289
- durability: res.record.durability,
20290
- notification_id: res.record.notification_id ?? null,
20291
- seconds_since_original_launch: bulkSecondsSinceOriginal ?? 0,
20292
- lead_ids: leadIds,
20293
- titles,
20294
- email,
20295
- phone,
20296
- preview,
20297
- message: `No new enrichment was ordered; quota not spent. An identical bulk was launched ${bulkSecondsSinceOriginal ?? 0}s ago. Unless the user asked NOT to wait (background/'I'll check later'), poll leadbay_bulk_enrich_status with this bulk_id for results; if they DID ask not to wait, hand back the bulk_id instead.`,
20298
- next_action: "Unless the user explicitly asked NOT to wait (background/'I'll check later'), poll leadbay_bulk_enrich_status({bulk_id}) until all_done \u2014 OR until overall_progress.done holds steady across several SPACED polls (~15\u201330s apart, ~90s\u20132min elapsed; unresolvable contacts never flip, so a reused bulk can stay all_done:false forever). include_contacts=true on the read you report from, then report the resolved enrichment in this turn \u2014 don't end your turn waiting or spin forever. If the user DID ask not to wait, hand back the bulk_id instead of polling."
20299
- };
20300
- }
20301
- }
20302
- ctx?.progress?.({
20303
- progress: 3,
20304
- total: 3,
20305
- message: `Launching enrichment for ${titles.length} title${titles.length === 1 ? "" : "s"}\u2026`
20306
- });
20307
- let launchResp = null;
20308
- try {
20309
- launchResp = await client.request("POST", "/leads/selection/enrichment/launch", { titles, email, phone });
20310
- } catch (err) {
20311
- const aborted = err?.name === "AbortError" || ctx?.signal?.aborted === true;
20312
- if (bulkRecord && tracker) {
20313
- try {
20314
- if (aborted) {
20315
- await tracker.markCancelled(bulkRecord.bulk_id);
20316
- } else {
20317
- await tracker.markFailed(bulkRecord.bulk_id);
20318
- }
20319
- } catch (e) {
20320
- ctx?.logger?.warn?.(`enrich_titles: tracker.${aborted ? "markCancelled" : "markFailed"} failed: ${e?.message ?? e}`);
20321
- }
20322
- }
20323
- if (err?.code === "QUOTA_EXCEEDED") {
20324
- return {
20325
- status: "quota_exceeded",
20326
- preview,
20327
- message: "Quota exceeded on launch",
20328
- retry_after_seconds: err?._meta?.retry_after ?? null
20329
- };
20330
- }
20331
- throw err;
20332
- }
20333
- const notificationId = launchResp?.notification_id ?? null;
20334
- if (bulkRecord && tracker) {
20335
- try {
20336
- await tracker.markLaunched(bulkRecord.bulk_id, notificationId);
20337
- } catch (e) {
20338
- ctx?.logger?.warn?.(`enrich_titles: tracker.markLaunched failed: ${e?.message ?? e}`);
20339
- return {
20340
- mode: "launched_tracker_pending",
20341
- launched: true,
20342
- preview,
20343
- bulk_id: bulkRecord.bulk_id,
20344
- launched_at: bulkRecord.launched_at,
20345
- durability: bulkRecord.durability,
20346
- // Surface the resolved lead IDs so the agent can follow the backend
20347
- // job per-lead — bulk_enrich_status is unusable for this stuck handle,
20348
- // and the caller may have omitted leadIds (wishlist default), so
20349
- // without these it has no identifiers to poll.
20350
- lead_ids: leadIds,
20351
- titles,
20352
- email,
20353
- phone,
20354
- message: "Enrichment job launched on the backend, but the local tracker record could not be flipped to 'launched' and will NOT heal on its own this session. leadbay_bulk_enrich_status({bulk_id}) will keep returning status:'pending' (BULK_PENDING) \u2014 do NOT poll it in a loop expecting completion. The backend job is running regardless; track it per-lead instead.",
20355
- next_action: "Do NOT poll leadbay_bulk_enrich_status \u2014 this bulk_id is stuck 'pending' and won't flip. If the user asked NOT to wait (background/'I'll check later'), just hand back the returned lead_ids and let them re-check later. Otherwise track results per lead via leadbay_get_contacts(leadId) / leadbay_research_lead_by_id for the returned lead_ids (re-check every ~30s). get_contacts returns each lead's FULL contact list, so only count/report contacts whose job_title matches the enriched titles (" + titles.join(", ") + ") \u2014 don't attribute a pre-existing CFO/Sales email to this run \u2014 and a contact is done only when the REQUESTED channel landed (requested email and/or phone_number present, not contact.enrichment.done alone). Stop once the done set plateaus (~90s\u20132min), then report the resolved contacts and name the rest. (The launch already succeeded \u2014 do not relaunch.)"
20356
- };
20357
- }
20358
- }
20625
+ const fingerprint = launchFingerprint([
20626
+ "enrich",
20627
+ leadIds,
20628
+ titles,
20629
+ email,
20630
+ phone,
20631
+ lensId
20632
+ ]);
20633
+ const claim = beginLaunch(fingerprint);
20634
+ if (claim.state === "in_flight") {
20635
+ return {
20636
+ mode: "launch_in_flight",
20637
+ launched: false,
20638
+ preview,
20639
+ lead_ids: leadIds,
20640
+ titles,
20641
+ message: `An identical enrichment was started ${claim.seconds_since}s ago and has not returned its job id yet. Nothing was launched twice and no quota was spent.`,
20642
+ next_action: "Wait a few seconds and call leadbay_enrich_titles again with the same arguments \u2014 it will hand back the job id once the first call settles. Do not treat this as a running job; there is no id to poll yet."
20643
+ };
20644
+ }
20645
+ const already = claim.state === "settled" ? claim.record : void 0;
20646
+ if (already) {
20647
+ return {
20648
+ mode: "already_launched",
20649
+ launched: true,
20650
+ preview,
20651
+ reused: true,
20652
+ seconds_since_original_launch: already.seconds_since,
20653
+ lead_ids: leadIds,
20654
+ titles,
20655
+ email,
20656
+ phone,
20657
+ notification_id: already.notification_id,
20658
+ launched_at: already.launched_at,
20659
+ message: `An identical enrichment was launched ${already.seconds_since}s ago; this call did NOT spend quota again. Poll the original job rather than relaunching.`,
20660
+ next_action: "Poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone, include_contacts: true}) until all_done, or until overall_progress.done holds steady across spaced polls (~15-30s apart) \u2014 unresolvable contacts never flip. Pass titles/email/phone every time: they scope counting to the roles and channel THIS run asked for, so a contact enriched earlier cannot report the run as finished. Then report what landed and name what didn't."
20661
+ };
20662
+ }
20663
+ ctx?.progress?.({
20664
+ progress: 3,
20665
+ total: 3,
20666
+ message: `Launching enrichment for ${titles.length} title${titles.length === 1 ? "" : "s"}\u2026`
20667
+ });
20668
+ let launchResp = null;
20669
+ try {
20670
+ launchResp = await client.request("POST", "/leads/selection/enrichment/launch", { titles, email, phone });
20671
+ } catch (err) {
20672
+ abandonLaunch(fingerprint);
20673
+ if (err?.code === "QUOTA_EXCEEDED") {
20359
20674
  return {
20360
- mode: "launched",
20675
+ status: "quota_exceeded",
20361
20676
  preview,
20362
- launched: true,
20363
- titles,
20364
- email,
20365
- phone,
20366
- // Always surface the resolved lead IDs — in the no-tracker branch there's
20367
- // no bulk_id to poll, and the caller may have omitted leadIds (wishlist
20368
- // default), so without these the agent has no identifiers to follow the
20369
- // job it just launched via leadbay_get_contacts / research_lead_by_id.
20370
- lead_ids: leadIds,
20371
- bulk_id: bulkRecord?.bulk_id,
20372
- launched_at: bulkRecord?.launched_at,
20373
- durability: bulkRecord?.durability,
20374
- notification_id: notificationId,
20375
- // Branch on bulkRecord FIRST: leadbay_bulk_enrich_status needs a real
20376
- // bulk_id (tracker handle). A notification_id can come back even with no
20377
- // tracker (legacy / OpenClaw raw-launch fall-through) — in that case
20378
- // bulk_id is undefined, so the agent must use the per-lead fallback, not
20379
- // poll a nonexistent bulk_id.
20380
- message: bulkRecord ? notificationId ? "Enrichment job launched (runs async). Unless the user asked NOT to wait (background/'I'll check later'), do NOT end your turn here \u2014 poll leadbay_bulk_enrich_status({bulk_id}) until all_done OR until progress plateaus (overall_progress.done stops climbing across spaced polls \u2014 unresolvable contacts keep all_done:false forever), then report the finished contacts yourself. (If the user DID ask not to wait, hand back the bulk_id instead. Either way, if you leave the conversation the completion also surfaces later via _meta.notifications / leadbay_account_status.notifications \u2014 but for a job you launched this turn and were NOT told to background, poll it now.)" : "Enrichment job launched (runs async). Unless the user asked NOT to wait (background/'I'll check later'), do NOT end your turn here \u2014 poll leadbay_bulk_enrich_status({bulk_id}) until all_done OR until progress plateaus (overall_progress.done stops climbing across spaced polls \u2014 unresolvable contacts keep all_done:false forever), then report the finished contacts yourself. (No notification id was returned, so there is NO automatic _meta.notifications completion for this job \u2014 if you background it or don't finish this turn, you (or the user) must poll leadbay_bulk_enrich_status({bulk_id}) again later; it will NOT surface on its own.)" : "Enrichment job launched. No bulk_id tracker configured. Unless the user asked NOT to wait (background/'I'll check later' \u2014 in which case hand back the lead_ids and let them re-check later), poll leadbay_get_contacts per lead (re-check every ~30s). get_contacts returns each lead's FULL contact list, so only count/report contacts whose job_title matches the enriched titles (" + titles.join(", ") + ") \u2014 don't attribute a pre-existing email of an unrelated role to this run \u2014 and a contact is done only when the REQUESTED channel landed (requested email and/or phone_number present, not contact.enrichment.done alone). Then report the results. Stop once the set of done contacts stops growing across a couple of spaced re-checks (~90s\u20132min elapsed): some contacts are unresolvable and never flip, so report the resolved ones and name the rest rather than polling forever.",
20381
- next_action: bulkRecord ? "Unless the user explicitly asked NOT to wait (background/'I'll check later'), poll leadbay_bulk_enrich_status({bulk_id}) in a loop until all_done \u2014 OR until overall_progress.done holds steady across several SPACED polls (~15\u201330s apart, ~90s\u20132min elapsed; don't call a plateau from the first back-to-back reads while the backend spins up, and don't call it a plateau while partial_failures is present \u2014 that's a transient fetch error, keep polling/respect retry_after). Pass include_contacts=true on the read you report from, then report the resolved enrichment in THIS turn (name what landed and what didn't). If the user DID ask not to wait, hand back the bulk_id instead of polling (and if notification_id is null, tell them to ask again later \u2014 nothing auto-surfaces)." : "Unless the user asked not to wait, re-check via leadbay_research_lead_by_id or leadbay_get_contacts for the returned lead_ids (every ~30s). get_contacts returns each lead's FULL contact list, so only count/report contacts whose job_title matches the enriched titles (" + titles.join(", ") + ") \u2014 don't attribute a pre-existing email of an unrelated role to this run. Treat a contact as done only when the REQUESTED channel landed \u2014 the requested email present and/or phone_number present \u2014 NOT contact.enrichment.done alone (it's already true for a contact enriched on the other channel earlier). Stop once the done set stops growing across a couple of spaced re-checks (~90s\u20132min elapsed) \u2014 unresolvable contacts never flip \u2014 then report the resolved ones and name the rest. Don't poll forever or end your turn waiting. If the user asked not to wait, hand back the lead_ids and let them re-check later."
20677
+ message: "Quota exceeded on launch",
20678
+ retry_after_seconds: err?._meta?.retry_after ?? null
20382
20679
  };
20383
20680
  }
20681
+ throw err;
20384
20682
  }
20683
+ const notificationId = launchResp?.notification_id ?? null;
20684
+ const remembered = rememberLaunch(fingerprint, notificationId);
20685
+ return {
20686
+ mode: "launched",
20687
+ preview,
20688
+ launched: true,
20689
+ titles,
20690
+ email,
20691
+ phone,
20692
+ // Always surfaced: these are the coordinates the agent polls with. There is
20693
+ // no server-side handle to look them up from, by design.
20694
+ lead_ids: leadIds,
20695
+ notification_id: notificationId,
20696
+ launched_at: remembered.launched_at,
20697
+ message: notificationId ? "Enrichment job launched (runs async). Unless the user asked NOT to wait, do NOT end your turn here \u2014 poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone}) until all_done OR until progress plateaus (overall_progress.done stops climbing across spaced polls \u2014 unresolvable contacts keep all_done:false forever), then report the finished contacts yourself. The notification_id keeps working across conversations and days; completion also surfaces via _meta.notifications / leadbay_account_status.notifications." : "Enrichment job launched, but the backend returned no notification_id, so there is no job id to poll. Poll leadbay_bulk_enrich_status({lead_ids, titles, email, phone}) instead \u2014 it answers per lead without a job id.",
20698
+ next_action: notificationId ? "Unless the user explicitly asked NOT to wait, poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone, include_contacts: true}) until all_done \u2014 OR until overall_progress.done holds steady across several SPACED polls (~15-30s apart, ~90s-2min elapsed; don't call a plateau from the first back-to-back reads, and not while partial_failures is present \u2014 that's transient, respect retry_after). Carry titles/email/phone on every poll \u2014 they scope counting to the roles and channel THIS run asked for, and without them a contact enriched months ago counts as done and all_done flips true before anything landed. Then report the resolved enrichment in THIS turn, naming what landed and what didn't. If the user DID ask not to wait, hand back the notification_id \u2014 it resolves later from any conversation." : "Poll leadbay_bulk_enrich_status({lead_ids, titles, email, phone, include_contacts: true}) every ~30s. It scopes counting to these titles and treats a contact as done only when the REQUESTED channel landed, so you do not have to do that yourself. Stop once overall_progress.done stops growing across a couple of spaced re-checks (~90s-2min) \u2014 unresolvable contacts never flip."
20699
+ };
20385
20700
  }
20386
20701
  async function launchEnrichment(client, args, ctx) {
20387
20702
  await client.acquireSelectionLock();
@@ -20411,7 +20726,7 @@ var enrichTitles = {
20411
20726
  // destructive because the dominant flow mutates state.
20412
20727
  destructiveHint: true,
20413
20728
  // Idempotent against the same selection + titles set (same hash → same
20414
- // bulk_id; backend silently no-ops on already-enriched contacts).
20729
+ // the launch; backend silently no-ops on already-enriched contacts).
20415
20730
  idempotentHint: true,
20416
20731
  openWorldHint: true
20417
20732
  },
@@ -20452,11 +20767,11 @@ var enrichTitles = {
20452
20767
  },
20453
20768
  outputSchema: {
20454
20769
  type: "object",
20455
- description: "Branchy return shape; the `mode` (or `status`) field tells the agent which branch it got. Modes: 'discover' (no titles passed), 'preview_only' (no enrichable contacts), 'dry_run', 'needs_confirmation' (paid launch withheld pending user consent), 'already_launched' (idempotent reuse), 'launched_tracker_pending' (rare, soft-fail), 'launched' (happy path). Status: 'quota_exceeded' (429).",
20770
+ description: "Branchy return shape; the `mode` (or `status`) field tells the agent which branch it got. Modes: 'discover' (no titles passed), 'preview_only' (no enrichable contacts), 'dry_run', 'needs_confirmation' (paid launch withheld pending user consent), 'already_launched' (idempotent reuse), 'launch_in_flight' (an identical launch is mid-flight and has no id yet), 'launched' (happy path). Status: 'quota_exceeded' (429).",
20456
20771
  properties: {
20457
20772
  mode: {
20458
20773
  type: "string",
20459
- description: "'discover' | 'preview_only' | 'dry_run' | 'needs_confirmation' | 'already_launched' | 'launched_tracker_pending' | 'launched'."
20774
+ description: "'discover' | 'preview_only' | 'dry_run' | 'needs_confirmation' | 'already_launched' | 'launch_in_flight' | 'launched'."
20460
20775
  },
20461
20776
  status: {
20462
20777
  type: "string",
@@ -20506,22 +20821,23 @@ var enrichTitles = {
20506
20821
  type: "object",
20507
20822
  description: "What dry_run WOULD have launched (titles, email, phone)."
20508
20823
  },
20509
- re_used: {
20510
- type: "boolean",
20511
- description: "True when an identical bulk was launched within the idempotency window (already_launched mode)."
20824
+ notification_id: {
20825
+ type: ["string", "null"],
20826
+ description: "The backend's job id. Carry it to leadbay_bulk_enrich_status. Null when the backend returned none \u2014 then poll by lead_ids instead."
20512
20827
  },
20513
- bulk_id: {
20514
- type: "string",
20515
- description: "UUIDv4 to poll via leadbay_bulk_enrich_status."
20828
+ lead_ids: {
20829
+ type: "array",
20830
+ description: "The leads this run enriched. Carry them to leadbay_bulk_enrich_status for per-lead progress; they also work when the notification is archived.",
20831
+ items: { type: "string" }
20832
+ },
20833
+ reused: {
20834
+ type: "boolean",
20835
+ description: "True when an identical launch inside the 5-minute window was reused instead of spending quota again."
20516
20836
  },
20517
20837
  launched_at: {
20518
20838
  type: "string",
20519
20839
  description: "ISO timestamp of the (re-used or fresh) launch."
20520
20840
  },
20521
- durability: {
20522
- type: "string",
20523
- description: "'file' (persisted bulks.json) or 'memory'."
20524
- },
20525
20841
  seconds_since_original_launch: {
20526
20842
  type: "number",
20527
20843
  description: "Age of the re-used bulk record (already_launched mode)."
@@ -20778,14 +21094,6 @@ var enrichTitles = {
20778
21094
  };
20779
21095
 
20780
21096
  // ../core/dist/composite/bulk-enrich-status.js
20781
- async function readNotification2(client, notificationId) {
20782
- try {
20783
- const page = await client.listNotifications({ archived: false, count: 50 });
20784
- return page.items.find((n) => n.id === notificationId) ?? null;
20785
- } catch {
20786
- return null;
20787
- }
20788
- }
20789
21097
  var STATUS_FETCH_CONCURRENCY = 5;
20790
21098
  async function pMap(items, fn, concurrency) {
20791
21099
  const out = new Array(items.length);
@@ -20814,44 +21122,45 @@ var bulkEnrichStatus = {
20814
21122
  inputSchema: {
20815
21123
  type: "object",
20816
21124
  properties: {
20817
- bulk_id: {
21125
+ notification_id: {
20818
21126
  type: "string",
20819
- description: "UUIDv4 returned by leadbay_enrich_titles at launch time. Required."
21127
+ description: "The `notification_id` returned by leadbay_enrich_titles. Gives the job-level counters in one call."
21128
+ },
21129
+ lead_ids: {
21130
+ type: "array",
21131
+ description: "The `lead_ids` the launch returned. Gives per-lead progress, and answers on its own if the notification is archived or has aged off page 1.",
21132
+ items: { type: "string" }
21133
+ },
21134
+ titles: {
21135
+ type: "array",
21136
+ description: "The `titles` the launch returned. Scopes progress to the roles THIS run enriched, so a lead's pre-existing CFO email cannot inflate a CEO run.",
21137
+ items: { type: "string" }
21138
+ },
21139
+ email: {
21140
+ type: "boolean",
21141
+ description: "The `email` flag the launch returned. A contact counts as done only once the requested channel has landed."
21142
+ },
21143
+ phone: {
21144
+ type: "boolean",
21145
+ description: "The `phone` flag the launch returned. Same rule as `email`."
20820
21146
  },
20821
21147
  include_contacts: {
20822
21148
  type: "boolean",
20823
21149
  description: "If true, return the full contact list per lead (email, phone, enrichment.done). Default false \u2014 cheap status polls."
20824
21150
  }
20825
21151
  },
20826
- required: ["bulk_id"],
21152
+ anyOf: [{ required: ["notification_id"] }, { required: ["lead_ids"] }],
20827
21153
  additionalProperties: false
20828
21154
  },
20829
21155
  outputSchema: {
20830
21156
  type: "object",
20831
21157
  properties: {
20832
- bulk_id: { type: "string", description: "Echoed UUIDv4 handle." },
21158
+ notification_id: { type: "string", description: "The backend job id; pass it back to poll again." },
20833
21159
  launched_at: { type: "string", description: "ISO timestamp of /enrichment/launch ack." },
20834
21160
  status: {
20835
21161
  type: "string",
20836
21162
  description: "'launched' on success. Errors return error envelopes (handled separately)."
20837
21163
  },
20838
- durability: {
20839
- type: "string",
20840
- description: "'persistent' (file-backed bulks.json) or 'memory' (LEADBAY_BULK_STORE_ALLOW_MEMORY)."
20841
- },
20842
- titles: {
20843
- type: "array",
20844
- description: "Titles ordered at launch time (echoed from the original enrich_titles call).",
20845
- items: { type: "string" }
20846
- },
20847
- email: { type: "boolean", description: "True if email enrichment was requested." },
20848
- phone: { type: "boolean", description: "True if phone enrichment was requested." },
20849
- lens_id: { type: "number", description: "Lens id used to scope the enrichment." },
20850
- leads: {
20851
- type: "array",
20852
- description: "Per-lead rollup: {lead_id, enrichment_progress:{done,total}, contacts? (when include_contacts=true)}.",
20853
- items: { type: "object" }
20854
- },
20855
21164
  overall_progress: {
20856
21165
  type: "object",
20857
21166
  description: "Aggregate progress across all leads.",
@@ -20875,243 +21184,180 @@ var bulkEnrichStatus = {
20875
21184
  items: { type: "object" }
20876
21185
  }
20877
21186
  },
20878
- required: ["bulk_id", "status", "leads", "overall_progress", "all_done"]
21187
+ required: ["status", "leads", "overall_progress", "all_done"]
20879
21188
  },
20880
21189
  execute: async (client, params, ctx) => {
20881
- if (!isValidBulkId(params.bulk_id)) {
20882
- return {
20883
- error: true,
20884
- code: "BULK_INVALID_ID",
20885
- message: "bulk_id is not a valid UUIDv4",
20886
- hint: "Pass the bulk_id returned by leadbay_enrich_titles verbatim."
20887
- };
20888
- }
20889
- if (!ctx?.bulkTracker) {
20890
- return {
20891
- error: true,
20892
- code: "BULK_TRACKER_UNAVAILABLE",
20893
- message: "No BulkTracker configured on this MCP instance",
20894
- hint: "This composite requires a BulkTracker in ToolContext. Upgrade to @leadbay/mcp \u22650.3.0 or run with LEADBAY_BULK_STORE_ALLOW_MEMORY=1."
20895
- };
20896
- }
20897
21190
  const includeContacts = params.include_contacts ?? false;
21191
+ const leadIds = params.lead_ids ?? [];
20898
21192
  const startMs = Date.now();
20899
- let record;
20900
- try {
20901
- record = await ctx.bulkTracker.get(params.bulk_id);
20902
- } catch (err) {
20903
- return {
20904
- error: true,
20905
- code: "BULK_STORE_UNAVAILABLE",
20906
- message: `Bulk store read failed: ${err?.message ?? err}`,
20907
- hint: "Check the file at $LEADBAY_BULK_STORE_PATH (default ~/.leadbay/bulks.json). Set LEADBAY_BULK_STORE_ALLOW_MEMORY=1 to fall back to in-memory storage on startup (handles won't survive restart)."
20908
- };
20909
- }
20910
- if (!record) {
20911
- return {
20912
- error: true,
20913
- code: "BULK_NOT_FOUND",
20914
- message: "No bulk record for that bulk_id",
20915
- hint: "The record may have aged out (30-day TTL) or the MCP process was restarted without persistence. Launch a new enrichment via leadbay_enrich_titles."
20916
- };
20917
- }
20918
- if (record.kind !== "enrich") {
20919
- return {
20920
- error: true,
20921
- code: "BULK_WRONG_KIND",
20922
- message: `This bulk_id was created by ${record.kind === "qualify" ? "leadbay_import_and_qualify" : "leadbay_import_leads"}, not leadbay_enrich_titles.`,
20923
- hint: record.kind === "qualify" ? "Call leadbay_qualify_status with this id instead." : "Call leadbay_import_status with this id instead.",
20924
- bulk_id: record.bulk_id
20925
- };
20926
- }
20927
- if (record.status === "pending") {
20928
- return {
20929
- error: true,
20930
- code: "BULK_PENDING",
20931
- message: "Bulk is in 'pending' state \u2014 the launch is in flight or the MCP crashed between launch and ack.",
20932
- hint: "Retry leadbay_bulk_enrich_status in a few seconds. If it persists >60s, relaunch via leadbay_enrich_titles.",
20933
- bulk_id: record.bulk_id,
20934
- launched_at: record.launched_at
20935
- };
20936
- }
20937
- if (record.status === "failed") {
21193
+ if (!params.notification_id && leadIds.length === 0) {
20938
21194
  return {
20939
21195
  error: true,
20940
- code: "BULK_LAUNCH_FAILED",
20941
- message: "The original /enrichment/launch POST failed; no backend enrichment was ordered.",
20942
- hint: "Call leadbay_enrich_titles again \u2014 the failed record won't block a fresh launch.",
20943
- bulk_id: record.bulk_id,
20944
- launched_at: record.launched_at
21196
+ code: "ENRICH_STATUS_INPUT_REQUIRED",
21197
+ message: "Pass notification_id and/or lead_ids",
21198
+ hint: "Both are in the leadbay_enrich_titles result. notification_id gives the job counters; lead_ids gives per-lead progress and works even when the notification has been archived."
20945
21199
  };
20946
21200
  }
20947
- if (record.status === "cancelled") {
20948
- return {
20949
- error: true,
20950
- code: "BULK_CANCELLED",
20951
- message: "The bulk was cancelled (ctx.signal aborted by the client mid-launch). No further work is in flight.",
20952
- hint: "Call leadbay_enrich_titles again with the same titles to relaunch \u2014 the cancelled record won't block a fresh launch.",
20953
- bulk_id: record.bulk_id,
20954
- launched_at: record.launched_at
20955
- };
20956
- }
20957
- const notifId = record.notification_id ?? null;
20958
- if (notifId) {
20959
- const n = await readNotification2(client, notifId);
20960
- if (n && n.bulk_progress) {
20961
- const bp = n.bulk_progress;
20962
- const inProgress = n.in_progress;
20963
- let leads2 = [];
20964
- const fastPartialFailures = [];
20965
- if (includeContacts) {
20966
- leads2 = await pMap(record.lead_ids, async (leadId) => {
20967
- try {
20968
- const out = await getContacts.execute(client, { leadId });
20969
- const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
20970
- const fe = Array.isArray(out?._fetch_errors) ? out._fetch_errors : [];
20971
- if (fe.length > 0) {
20972
- fastPartialFailures.push({
20973
- lead_id: leadId,
20974
- code: fe[0]?.code ?? "FETCH_ERROR",
20975
- ...fe[0]?.retry_after !== void 0 ? { retry_after: fe[0].retry_after } : {}
20976
- });
20977
- }
20978
- return { lead_id: leadId, contacts };
20979
- } catch (err) {
20980
- fastPartialFailures.push({
20981
- lead_id: leadId,
20982
- code: err?.code ?? "UNKNOWN",
20983
- ...err?._meta?.retry_after !== void 0 ? { retry_after: err._meta.retry_after } : {}
20984
- });
20985
- return { lead_id: leadId };
20986
- }
20987
- }, STATUS_FETCH_CONCURRENCY);
20988
- } else {
20989
- leads2 = record.lead_ids.map((id) => ({ lead_id: id }));
20990
- }
20991
- ctx?.logger?.info?.(`bulk.status_checked_via_notification bulk_id=${record.bulk_id} notification_id=${notifId} done=${bp.success_count}/${bp.total_count} in_progress=${inProgress} wall_ms=${Date.now() - startMs}`);
20992
- const isReportRead = !inProgress || includeContacts;
20993
- const creditsRemaining2 = isReportRead ? await readCreditsRemaining(client, true) : null;
21201
+ let bp = null;
21202
+ let inProgress = null;
21203
+ let launchedAt = null;
21204
+ if (params.notification_id) {
21205
+ const n = await readNotificationById(client, params.notification_id);
21206
+ if (n && inferKind(n) !== "bulk_enrich") {
21207
+ const kind = inferKind(n);
20994
21208
  return {
20995
- bulk_id: record.bulk_id,
20996
- notification_id: notifId,
20997
- launched_at: record.launched_at,
20998
- status: record.status,
20999
- durability: record.durability,
21000
- titles: record.titles,
21001
- email: record.email,
21002
- phone: record.phone,
21003
- lens_id: record.lens_id,
21004
- leads: leads2,
21005
- overall_progress: {
21006
- done: bp.success_count + bp.failure_count + bp.quota_hit_count,
21007
- total: bp.total_count,
21008
- done_ratio: bp.total_count === 0 ? 0 : (bp.success_count + bp.failure_count + bp.quota_hit_count) / bp.total_count
21009
- },
21010
- bulk_progress: bp,
21011
- in_progress: inProgress,
21012
- all_done: !inProgress,
21013
- ...fastPartialFailures.length > 0 ? { partial_failures: fastPartialFailures } : {},
21014
- ...isReportRead ? { credits_remaining: creditsRemaining2 } : {},
21015
- ...bp.quota_hit_count > 0 ? {
21016
- quota_hit_hint: "Some contacts could not be enriched because the AI-credits quota was hit. Top up via leadbay_create_topup_link or wait for the window reset."
21017
- } : {}
21209
+ error: true,
21210
+ code: "ENRICH_JOB_WRONG_KIND",
21211
+ message: `That notification_id is a ${kind === "bulk_qualify" ? "lead qualification" : kind === "import" ? "file import" : "non-bulk"} notification, not a contact enrichment`,
21212
+ hint: kind === "bulk_qualify" ? "Poll it with leadbay_qualify_status({notification_id}) instead." : kind === "import" ? "Poll it with leadbay_import_status({importIds}) instead \u2014 the import ids came back from the import launch." : "Pass the notification_id returned by leadbay_enrich_titles."
21018
21213
  };
21019
21214
  }
21020
- ctx?.logger?.info?.(`bulk_enrich_status: notification ${notifId} not yet visible; falling back to per-lead fan-out`);
21021
- }
21022
- let doneSoFar = 0;
21023
- const totalLeads = record.lead_ids.length;
21024
- const results = await pMap(record.lead_ids, async (leadId) => {
21025
- try {
21026
- const out = await getContacts.execute(client, { leadId });
21027
- const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
21028
- const wantTitles = new Set((record.titles ?? []).map((t) => t.trim().toLowerCase()));
21029
- const enrichable = contacts.filter((c) => c && c.enrichment && (wantTitles.size === 0 || typeof c.job_title === "string" && wantTitles.has(c.job_title.trim().toLowerCase())));
21030
- const channelResolved = (c) => {
21031
- if (c.enrichment?.done !== true)
21032
- return false;
21033
- if (record.email && !c.email)
21034
- return false;
21035
- if (record.phone && !c.phone_number)
21036
- return false;
21037
- return true;
21038
- };
21039
- const done = enrichable.filter(channelResolved).length;
21040
- const total = enrichable.length;
21041
- doneSoFar += 1;
21042
- ctx?.progress?.({
21043
- progress: doneSoFar,
21044
- total: totalLeads,
21045
- message: `Fetched contacts for ${leadId} (${doneSoFar}/${totalLeads})`
21046
- });
21047
- return {
21048
- kind: "ok",
21049
- lead_id: leadId,
21050
- done,
21051
- total,
21052
- contacts: includeContacts ? contacts : void 0
21053
- };
21054
- } catch (err) {
21055
- doneSoFar += 1;
21056
- ctx?.progress?.({
21057
- progress: doneSoFar,
21058
- total: totalLeads,
21059
- message: `Fetch failed for ${leadId} (${doneSoFar}/${totalLeads}): ${err?.code ?? "UNKNOWN"}`
21060
- });
21215
+ if (n) {
21216
+ bp = n.bulk_progress;
21217
+ inProgress = n.in_progress;
21218
+ launchedAt = n.created_at;
21219
+ } else if (leadIds.length === 0) {
21061
21220
  return {
21062
- kind: "fail",
21063
- lead_id: leadId,
21064
- code: err?.code ?? "UNKNOWN",
21065
- retry_after: err?._meta?.retry_after
21221
+ error: true,
21222
+ code: "ENRICH_JOB_NOT_FOUND",
21223
+ message: "That notification_id is not in the recent notification list",
21224
+ hint: "The lookup scans your recent unarchived notifications; an archived job, or one behind many newer ones, will not be found. Re-call with the `lead_ids` the launch returned \u2014 that answers without the notification."
21066
21225
  };
21067
21226
  }
21068
- }, STATUS_FETCH_CONCURRENCY);
21069
- const leads = [];
21070
- const partialFailures = [];
21071
- let totalDone = 0;
21072
- let totalAll = 0;
21073
- for (const r of results) {
21074
- if (r.kind === "fail") {
21075
- partialFailures.push({
21227
+ }
21228
+ if (leadIds.length > 0) {
21229
+ const wantTitles = new Set((params.titles ?? []).map((t) => t.trim().toLowerCase()));
21230
+ const channelResolved = (c) => {
21231
+ if (c?.enrichment?.done !== true)
21232
+ return false;
21233
+ if (params.email && !c.email)
21234
+ return false;
21235
+ if (params.phone && !c.phone_number)
21236
+ return false;
21237
+ return true;
21238
+ };
21239
+ let doneSoFar = 0;
21240
+ const totalLeads = leadIds.length;
21241
+ const results = await pMap(leadIds, async (leadId) => {
21242
+ try {
21243
+ const out = await getContacts.execute(client, { leadId });
21244
+ const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
21245
+ const enrichable = contacts.filter((c) => c && c.enrichment && (wantTitles.size === 0 || typeof c.job_title === "string" && wantTitles.has(c.job_title.trim().toLowerCase())));
21246
+ const fe = Array.isArray(out?._fetch_errors) ? out._fetch_errors : [];
21247
+ doneSoFar += 1;
21248
+ ctx?.progress?.({
21249
+ progress: doneSoFar,
21250
+ total: totalLeads,
21251
+ message: `Fetched contacts for ${leadId} (${doneSoFar}/${totalLeads})`
21252
+ });
21253
+ if (fe.length > 0) {
21254
+ return {
21255
+ kind: "fail",
21256
+ lead_id: leadId,
21257
+ code: fe[0]?.code ?? "FETCH_ERROR",
21258
+ ...fe[0]?.retry_after !== void 0 ? { retry_after: fe[0].retry_after } : {}
21259
+ };
21260
+ }
21261
+ return {
21262
+ kind: "ok",
21263
+ lead_id: leadId,
21264
+ done: enrichable.filter(channelResolved).length,
21265
+ total: enrichable.length,
21266
+ ...includeContacts ? { contacts } : {}
21267
+ };
21268
+ } catch (err) {
21269
+ doneSoFar += 1;
21270
+ ctx?.progress?.({
21271
+ progress: doneSoFar,
21272
+ total: totalLeads,
21273
+ message: `Fetch failed for ${leadId} (${doneSoFar}/${totalLeads}): ${err?.code ?? "UNKNOWN"}`
21274
+ });
21275
+ return {
21276
+ kind: "fail",
21277
+ lead_id: leadId,
21278
+ code: err?.code ?? "UNKNOWN",
21279
+ ...err?._meta?.retry_after !== void 0 ? { retry_after: err._meta.retry_after } : {}
21280
+ };
21281
+ }
21282
+ }, STATUS_FETCH_CONCURRENCY);
21283
+ const leads = [];
21284
+ const partialFailures = [];
21285
+ let totalDone = 0;
21286
+ let totalAll = 0;
21287
+ for (const r of results) {
21288
+ if (r.kind === "fail") {
21289
+ partialFailures.push({
21290
+ lead_id: r.lead_id,
21291
+ code: r.code,
21292
+ ...r.retry_after !== void 0 ? { retry_after: r.retry_after } : {}
21293
+ });
21294
+ continue;
21295
+ }
21296
+ leads.push({
21076
21297
  lead_id: r.lead_id,
21077
- code: r.code,
21078
- ...r.retry_after !== void 0 ? { retry_after: r.retry_after } : {}
21298
+ ...r.contacts ? { contacts: r.contacts } : {},
21299
+ enrichment_progress: { done: r.done, total: r.total }
21079
21300
  });
21080
- continue;
21301
+ totalDone += r.done;
21302
+ totalAll += r.total;
21081
21303
  }
21082
- leads.push({
21083
- lead_id: r.lead_id,
21084
- ...r.contacts ? { contacts: r.contacts } : {},
21085
- enrichment_progress: { done: r.done, total: r.total }
21086
- });
21087
- totalDone += r.done;
21088
- totalAll += r.total;
21304
+ const allDone = totalAll > 0 && totalDone === totalAll && partialFailures.length === 0;
21305
+ ctx?.logger?.info?.(`bulk.status leads=${leadIds.length} done=${totalDone}/${totalAll} wall_ms=${Date.now() - startMs}`);
21306
+ const creditsRemaining2 = allDone ? await readCreditsRemaining(client, true) : null;
21307
+ return {
21308
+ ...params.notification_id ? { notification_id: params.notification_id } : {},
21309
+ ...launchedAt ? { launched_at: launchedAt } : {},
21310
+ status: allDone ? "complete" : "launched",
21311
+ // Echo what was asked for, so the reply states its own scope.
21312
+ ...params.titles ? { titles: params.titles } : {},
21313
+ ...params.email !== void 0 ? { email: params.email } : {},
21314
+ ...params.phone !== void 0 ? { phone: params.phone } : {},
21315
+ leads,
21316
+ overall_progress: {
21317
+ done: totalDone,
21318
+ total: totalAll,
21319
+ done_ratio: totalAll === 0 ? 0 : totalDone / totalAll
21320
+ },
21321
+ ...bp ? { bulk_progress: bp } : {},
21322
+ ...inProgress !== null ? { in_progress: inProgress } : {},
21323
+ all_done: allDone,
21324
+ ...partialFailures.length > 0 ? { partial_failures: partialFailures } : {},
21325
+ ...allDone ? { credits_remaining: creditsRemaining2 } : {},
21326
+ ...bp && bp.quota_hit_count > 0 ? {
21327
+ quota_hit_hint: "Some contacts could not be enriched because the AI-credits quota was hit. Top up via leadbay_create_topup_link or wait for the window reset."
21328
+ } : {}
21329
+ };
21089
21330
  }
21090
- const overallProgress = {
21091
- done: totalDone,
21092
- total: totalAll,
21093
- done_ratio: totalAll === 0 ? 0 : totalDone / totalAll
21094
- };
21095
- const allDone = totalAll > 0 && totalDone === totalAll && partialFailures.length === 0;
21096
- ctx?.logger?.info?.(`bulk.status_checked bulk_id=${record.bulk_id} done=${totalDone} total=${totalAll} wall_ms=${Date.now() - startMs}`);
21097
- let creditsRemaining = null;
21098
- if (allDone) {
21099
- creditsRemaining = await readCreditsRemaining(client, true);
21331
+ if (!bp) {
21332
+ return {
21333
+ error: true,
21334
+ code: "ENRICH_JOB_NO_COUNTERS",
21335
+ message: `This enrichment notification carries no per-contact counters; the backend reports it as ${inProgress ? "still running" : "finished"}`,
21336
+ hint: "Re-call with the `lead_ids` returned by leadbay_enrich_titles (plus titles/email/phone) \u2014 that path counts contacts directly and works whether or not the notification has counters.",
21337
+ ...inProgress !== null ? { in_progress: inProgress } : {},
21338
+ ...launchedAt ? { launched_at: launchedAt } : {}
21339
+ };
21100
21340
  }
21341
+ const done = bp.success_count + bp.failure_count + bp.quota_hit_count;
21342
+ const isReportRead = !inProgress;
21343
+ const creditsRemaining = isReportRead ? await readCreditsRemaining(client, true) : null;
21101
21344
  return {
21102
- bulk_id: record.bulk_id,
21103
- launched_at: record.launched_at,
21104
- status: record.status,
21105
- durability: record.durability,
21106
- titles: record.titles,
21107
- email: record.email,
21108
- phone: record.phone,
21109
- lens_id: record.lens_id,
21110
- leads,
21111
- overall_progress: overallProgress,
21112
- all_done: allDone,
21113
- ...allDone ? { credits_remaining: creditsRemaining } : {},
21114
- ...partialFailures.length > 0 ? { partial_failures: partialFailures } : {}
21345
+ notification_id: params.notification_id,
21346
+ launched_at: launchedAt,
21347
+ status: inProgress ? "launched" : "complete",
21348
+ leads: [],
21349
+ overall_progress: {
21350
+ done,
21351
+ total: bp.total_count,
21352
+ done_ratio: bp.total_count === 0 ? 0 : done / bp.total_count
21353
+ },
21354
+ bulk_progress: bp,
21355
+ in_progress: inProgress,
21356
+ all_done: !inProgress,
21357
+ ...isReportRead ? { credits_remaining: creditsRemaining } : {},
21358
+ ...bp.quota_hit_count > 0 ? {
21359
+ quota_hit_hint: "Some contacts could not be enriched because the AI-credits quota was hit. Top up via leadbay_create_topup_link or wait for the window reset."
21360
+ } : {}
21115
21361
  };
21116
21362
  }
21117
21363
  };
@@ -23151,7 +23397,7 @@ var sendFeedback = {
23151
23397
 
23152
23398
  // ../core/dist/artifact-runtime.generated.js
23153
23399
  var ARTIFACT_KIT_VERSION = "0.5.0";
23154
- 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);})();';
23400
+ 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 _=`\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 S(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(S(e)??"tool call failed",{raw:e});if("structuredContent"in r&&r.structuredContent!=null)return r.structuredContent;let t=S(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=_,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()}},u=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 d=r.value==null?"":String(r.value);e.value!==d&&(e.value=d)}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 j(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 W(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 $(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 u({autoLoad:!1,load:()=>l("leadbay_account_history",{leadId:e,_triggered_by:r})})}function K(e,r){return new u({autoLoad:!1,load:()=>l("leadbay_research_lead_by_id",{leadId:e,_triggered_by:r})})}function J(e){let r=null;return new u({...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}),o=Array.isArray(t?.lead_ids)?t.lead_ids:[],n=t?.notification_id??null;if(r=n||o.length>0?{notification_id:n,lead_ids:o}:null,!r)return{...t,all_done:!0,no_job:!0}}return l("leadbay_bulk_enrich_status",{...r.notification_id?{notification_id:r.notification_id}:{},...r.lead_ids.length>0?{lead_ids:r.lead_ids}:{},...e.titles?{titles:e.titles}:{},...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},_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 d=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=d.leads??d.items??[];return{items:w,total:d.total_leads??d.pagination?.total??w.length}}})}function ee(e){return new u({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 u(e),list:e=>new h(e),bindSelect:D,bindValue:z,bindAction:F,campaigns:$,outreach:V,note:G,like:Z,dislike:Y,leadStatus:j,setStatus:W,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);})();';
23155
23401
  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.';
23156
23402
 
23157
23403
  // ../core/dist/tools/artifact-kit.js
@@ -24216,7 +24462,7 @@ function buildProtocolPrimitivesParagraph(has) {
24216
24462
  }
24217
24463
  if (longRunners.length > 0) {
24218
24464
  parts.push(
24219
- "(2) `notifications/cancelled` \u2014 when the user clicks Cancel in the host UI, the polling loop exits within \u22642 seconds AND the bulk-store entry transitions to 'cancelled'; subsequent status polls return `BULK_CANCELLED` so the agent stops polling."
24465
+ "(2) `notifications/cancelled` \u2014 when the user clicks Cancel in the host UI, the polling loop exits within \u22642 seconds. The job itself keeps running on the backend; poll its notification_id / importIds later to pick it up."
24220
24466
  );
24221
24467
  } else {
24222
24468
  parts.push(
@@ -24736,7 +24982,6 @@ ${url}
24736
24982
  const shapeError = findShapeMismatch(tool, args);
24737
24983
  const result = shapeError ?? await runWithRequestSignal(extra.signal, () => tool.execute(client, args, {
24738
24984
  logger: opts.logger,
24739
- bulkTracker: opts.bulkTracker,
24740
24985
  notificationsInbox: opts.notificationsInbox,
24741
24986
  signal: extra.signal,
24742
24987
  progress,
@@ -25165,7 +25410,7 @@ function parseWriteEnv(env = process.env) {
25165
25410
  }
25166
25411
 
25167
25412
  // src/http-server.ts
25168
- var VERSION = true ? "0.34.1" : "0.0.0-dev";
25413
+ var VERSION = true ? "0.35.0" : "0.0.0-dev";
25169
25414
  var PORT = Number(process.env.PORT ?? 8080);
25170
25415
  var HOST = process.env.HOST ?? "0.0.0.0";
25171
25416
  var logger = {
@@ -25570,7 +25815,7 @@ var isEntrypoint = (() => {
25570
25815
  }
25571
25816
  })();
25572
25817
  if (isEntrypoint) {
25573
- const _boot = randomUUID3();
25818
+ const _boot = randomUUID2();
25574
25819
  serve({ fetch: app.fetch, port: PORT, hostname: HOST }, (info) => {
25575
25820
  process.stderr.write(
25576
25821
  `leadbay-mcp-http ${VERSION} listening on http://${info.address}:${info.port} (boot=${_boot})