@leadbay/mcp 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -6832,8 +6832,10 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
6832
6832
 
6833
6833
  \`queued\` \u2192 \u2705 "Queued <N> extra leads on lens <id>. Pull in ~30s." Do NOT
6834
6834
  list \`accepted_seeds\`; they're internal.
6835
- \`quota_exceeded\` \u2192 render three options via your host's choice widget (\`ask_user_input_v0\` or \`AskUserQuestion\`) (smaller
6836
- count / wait until reset / upgrade).
6835
+ \`no_candidates\` \u2192 \u26D4 surface \`reason.message\`, name the criteria, offer
6836
+ \`leadbay_adjust_audience\`. Never re-call this tool on that lens.
6837
+ \`quota_exceeded\` \u2192 three options via the host's choice widget
6838
+ (smaller count / wait for reset / upgrade).
6837
6839
  \`refresh_in_progress\` \u2192 "lens is filling, retry in a minute".
6838
6840
  \`no_valid_seeds\` \u2192 silently re-call \`leadbay_seed_candidates\`, retry once.
6839
6841
 
@@ -6853,6 +6855,9 @@ Queue an additive extra-refill on a lens \u2014 more leads on the same criteria,
6853
6855
  - \`status: "quota_exceeded"\` \u2014 daily LENS_EXTRA_REFILL hit. Response carries \`quota: {used_today, resets_at}\` + a \`message\` to surface. **Render three options via your host's choice widget (\`ask_user_input_v0\` or \`AskUserQuestion\`)**: (1) smaller \`extra_count\`, (2) wait until \`resets_at\`, (3) upgrade plan (TIER1=150, TIER2=1000). Do NOT silently retry.
6854
6856
  - \`status: "refresh_in_progress"\` \u2014 a refresh or extra-refill is already running. Tell the user to wait and call \`leadbay_pull_leads\` in ~30s.
6855
6857
  - \`status: "no_valid_seeds"\` \u2014 seeds went stale. Silently re-call \`leadbay_seed_candidates\` and retry once; only surface to the user if the second attempt also fails.
6858
+ - \`status: "no_candidates"\` \u2014 **the refill was NOT queued.** The lens's candidate pool is empty, so a refill would report success, consume no quota and deliver nothing. \`reason\` carries the same \`{code, message, retryable, criteria?, narrow_locations?}\` shape \`leadbay_pull_leads\` returns in \`empty_reason\`, with \`retryable: false\`. **Stop. Do not re-call this tool on this lens** \u2014 the outcome cannot change until the audience changes. Surface \`reason.message\`, name the criteria in play, and offer \`leadbay_adjust_audience\` (or \`leadbay_pull_followups\` when \`reason.code\` is \`no_new_leads\` and the lens already holds leads).
6859
+
6860
+ **Extendability is checked before the write.** Every response carries \`available_count\` \u2014 how many leads a refill could still draw, read from the lens's own pool. \`0\` means the call was refused (\`no_candidates\`); \`null\` means the pool could not be read and the refill was queued anyway. An empty lens is NOT evidence of a broken refill: it is usually a lens that never had candidates. Reach for \`leadbay_adjust_audience\`, not another \`leadbay_extend_lens\`.
6856
6861
 
6857
6862
  WHEN TO USE: when the user has a bigger appetite than the daily lens fill delivers \u2014 they want MORE of the same kind of leads, on demand. Canonical phrasings: "I want more leads on this lens", "extend the lens", "give me a bigger batch today". The \`leadbay_extend_my_lens\` prompt is the user-facing entry point that orchestrates the whole flow.
6858
6863
 
@@ -6893,6 +6898,8 @@ Pick the row matching the response \`status\`. Seed-picking is internal; do NOT
6893
6898
  | \`quota_exceeded\` | "Upgrade plan for a higher daily limit" | (no call \u2014 direct user to contact account manager / sales) |
6894
6899
  | \`refresh_in_progress\` | "Lens is already filling \u2014 pull leads in a minute" | \`leadbay_pull_leads()\` (after a short wait) |
6895
6900
  | \`no_valid_seeds\` | (silent retry \u2014 re-call \`leadbay_seed_candidates\` then \`leadbay_extend_lens\`) | internal \u2014 only surface if the second attempt also fails |
6901
+ | \`no_candidates\` | "Widen the audience \u2014 this lens has nothing left to add" | \`leadbay_adjust_audience()\` \u2014 never \`leadbay_extend_lens\` again |
6902
+ | \`no_candidates\` (\`reason.code: no_new_leads\`) | "Work the leads already in the lens" | \`leadbay_pull_followups()\` |
6896
6903
 
6897
6904
  If nothing matches cleanly, default to "pull leads now to see what's queued" \u2014 never invent a tool that doesn't exist.
6898
6905
  `;
@@ -8596,6 +8603,11 @@ WHEN NOT TO USE: when the user has named a specific lens \u2014 pass \`lensId\`
8596
8603
 
8597
8604
  The active lens can change between calls (5-min cache + backend \`last_requested_lens\`). If a multi-step workflow depends on staying on one lens, **capture \`response.lens.id\` from the first response and pass it as the \`lensId\` argument on every subsequent Leadbay call** \u2014 including re-pulls, bulk qualifies, and research. (Field-name caveat: response nests it as \`lens.id\`; the parameter is \`lensId\`.) Re-pulling without \`lensId\` after a long-running tool may silently switch to a different lens and discard prior work.
8598
8605
 
8606
+ **EMPTY BATCH \u2014 route on \`empty_reason\`, never loop.** When \`leads\` is empty the response carries \`empty_reason: {code, message, retryable, criteria?, narrow_locations?}\`. \`retryable\` is the only field that decides what you do next:
8607
+
8608
+ - \`retryable: true\` (always \`code: "computing"\`) \u2014 the lens is still building. Say so, pull ONCE more in ~30s. Do not call it empty.
8609
+ - \`retryable: false\` \u2014 no amount of re-pulling, lens-switching or \`leadbay_extend_lens\` can produce leads on these criteria. **Stop calling tools.** Surface \`message\` to the user, name the criteria from \`criteria\` (and \`narrow_locations\` first when present \u2014 a city-scale geo scope is the usual culprit), and offer \`leadbay_adjust_audience\` to widen. A refill on a zero-candidate lens answers "queued", consumes no quota and delivers nothing, so retrying reads as progress while achieving none (product#3995).
8610
+
8599
8611
  ---
8600
8612
 
8601
8613
  ## RENDERING \u2014 markdown table, three columns, score-bar driven
@@ -16481,6 +16493,111 @@ var init_prepare_outreach = __esm({
16481
16493
  }
16482
16494
  });
16483
16495
 
16496
+ // ../core/dist/composite/_empty-lens-reason.js
16497
+ function criteriaOf(filter) {
16498
+ return filter?.lens_filter?.items?.flatMap((i) => i.criteria ?? []) ?? [];
16499
+ }
16500
+ function summariseCriteria(criteria) {
16501
+ const out = {};
16502
+ for (const c of criteria) {
16503
+ if (c.type === "sector_ids") {
16504
+ const key = c.is_excluded ? "excluded_sector_ids" : "sector_ids";
16505
+ out[key] = [...out[key] ?? [], ...c.sectors ?? []];
16506
+ } else if (c.type === "location_ids") {
16507
+ const key = c.is_excluded ? "excluded_location_ids" : "location_ids";
16508
+ out[key] = [...out[key] ?? [], ...c.locations ?? []];
16509
+ } else if (c.type === "size" && !c.is_excluded) {
16510
+ out.sizes = [...out.sizes ?? [], ...c.sizes ?? []];
16511
+ }
16512
+ }
16513
+ return Object.keys(out).length > 0 ? out : void 0;
16514
+ }
16515
+ function narrowLocationsOf(filter, criteria) {
16516
+ const included = new Set(criteria.filter((c) => c.type === "location_ids" && !c.is_excluded).flatMap((c) => c.locations ?? []));
16517
+ if (included.size === 0)
16518
+ return [];
16519
+ const results = filter?.locations?.results ?? [];
16520
+ return results.filter((r) => typeof r.id === "string" && included.has(r.id) && typeof r.level === "number" && r.level >= CITY_LEVEL).map((r) => ({
16521
+ id: r.id,
16522
+ name: typeof r.name === "string" ? r.name : "",
16523
+ level: r.level
16524
+ }));
16525
+ }
16526
+ function narrowGeoSentence(narrow) {
16527
+ const names = narrow.map((n) => n.name).filter(Boolean);
16528
+ if (names.length === 0)
16529
+ return "";
16530
+ return ` Its geography is pinned to ${names.join(", ")} \u2014 a city-scale area or smaller, which on an empty lens is almost always the criterion to relax first.`;
16531
+ }
16532
+ async function readAudienceShape(client, lensId) {
16533
+ let filter = null;
16534
+ try {
16535
+ filter = await client.request("GET", `/lenses/${lensId}/filter`);
16536
+ } catch {
16537
+ }
16538
+ const criteria = criteriaOf(filter);
16539
+ const summary = summariseCriteria(criteria);
16540
+ const narrow = narrowLocationsOf(filter, criteria);
16541
+ return {
16542
+ ...summary ? { criteria: summary } : {},
16543
+ ...narrow.length > 0 ? { narrow_locations: narrow } : {},
16544
+ geoSentence: narrowGeoSentence(narrow)
16545
+ };
16546
+ }
16547
+ async function diagnoseEmptyLens(client, lensId, computing) {
16548
+ if (computing.wishlist || computing.scores) {
16549
+ return {
16550
+ code: "computing",
16551
+ retryable: true,
16552
+ message: "This lens is still computing its leads. Pull again in ~30s \u2014 do NOT report it as empty yet."
16553
+ };
16554
+ }
16555
+ let row;
16556
+ try {
16557
+ const lenses = await client.request("GET", "/lenses");
16558
+ row = lenses.find((l) => String(l.id) === String(lensId));
16559
+ } catch {
16560
+ }
16561
+ const { geoSentence: geo, ...extras } = await readAudienceShape(client, lensId);
16562
+ const summary = extras.criteria;
16563
+ if (row?.not_enough_lead_candidates) {
16564
+ return {
16565
+ code: "no_candidates",
16566
+ retryable: false,
16567
+ message: "This lens's criteria match no companies in the database, so it cannot fill." + geo + " Tell the user and offer to widen the audience (leadbay_adjust_audience) \u2014 extending or re-pulling will not help.",
16568
+ ...extras
16569
+ };
16570
+ }
16571
+ if (row?.not_enough_new_leads) {
16572
+ return {
16573
+ code: "no_new_leads",
16574
+ retryable: false,
16575
+ message: "Every company matching this lens has already been delivered \u2014 there are no NEW leads left on these criteria. Tell the user; offer to widen the audience (leadbay_adjust_audience) or work the existing leads via leadbay_pull_followups.",
16576
+ ...extras
16577
+ };
16578
+ }
16579
+ if (summary) {
16580
+ return {
16581
+ code: "audience_too_narrow",
16582
+ retryable: false,
16583
+ message: "This lens is finished computing and holds zero leads: its criteria intersect to nothing." + geo + " Tell the user which criteria are in play and offer to widen the audience (leadbay_adjust_audience). Do NOT call leadbay_extend_lens \u2014 a refill on a zero-candidate lens reports queued, consumes no quota, and delivers nothing.",
16584
+ ...extras
16585
+ };
16586
+ }
16587
+ return {
16588
+ code: "unknown",
16589
+ retryable: false,
16590
+ message: "This lens is finished computing and holds zero leads, and carries no audience criteria that would explain it. Report this to the user rather than retrying; leadbay_report_friction is the way to flag it to the Leadbay team."
16591
+ };
16592
+ }
16593
+ var CITY_LEVEL;
16594
+ var init_empty_lens_reason = __esm({
16595
+ "../core/dist/composite/_empty-lens-reason.js"() {
16596
+ "use strict";
16597
+ CITY_LEVEL = 7;
16598
+ }
16599
+ });
16600
+
16484
16601
  // ../core/dist/composite/pull-leads.js
16485
16602
  function normalizeLinkedinPage3(v) {
16486
16603
  if (v == null)
@@ -16561,6 +16678,7 @@ var init_pull_leads = __esm({
16561
16678
  "../core/dist/composite/pull-leads.js"() {
16562
16679
  "use strict";
16563
16680
  init_agent_memory();
16681
+ init_empty_lens_reason();
16564
16682
  init_tool_descriptions_generated();
16565
16683
  pullLeads = {
16566
16684
  name: "leadbay_pull_leads",
@@ -16626,6 +16744,34 @@ var init_pull_leads = __esm({
16626
16744
  type: "boolean",
16627
16745
  description: "True if scoring is still running."
16628
16746
  },
16747
+ empty_reason: {
16748
+ type: ["object", "null"],
16749
+ description: "Why this LENS holds zero leads. null whenever leads were returned, and null when this page is empty only because it is past the end of a non-empty lens. `retryable` is the field to route on: true ONLY on code=computing (pull again in ~30s). On every other code re-pulling and leadbay_extend_lens are both futile \u2014 a refill on a zero-candidate lens answers 'queued', consumes no quota and delivers nothing \u2014 so surface `message` to the user and offer leadbay_adjust_audience instead of retrying.",
16750
+ properties: {
16751
+ code: {
16752
+ type: "string",
16753
+ description: "computing | no_candidates | no_new_leads | audience_too_narrow | unknown"
16754
+ },
16755
+ message: {
16756
+ type: "string",
16757
+ description: "The line to surface to the user."
16758
+ },
16759
+ retryable: {
16760
+ type: "boolean",
16761
+ description: "True only while the lens is still computing. False means no amount of re-pulling or extending can produce leads."
16762
+ },
16763
+ criteria: {
16764
+ type: "object",
16765
+ description: "The lens criteria in play \u2014 what the user would have to relax. Present when the lens carries any."
16766
+ },
16767
+ narrow_locations: {
16768
+ type: "array",
16769
+ description: "Include-locations that resolved to a city-scale area or smaller ({id, name, level}). On an empty lens, name these first: this is the fingerprint of a whole-country location that fell through to a same-named village (product#3951).",
16770
+ items: { type: "object" }
16771
+ }
16772
+ },
16773
+ required: ["code", "message", "retryable"]
16774
+ },
16629
16775
  next_steps: {
16630
16776
  type: ["object", "null"],
16631
16777
  description: "Ready-made NEXT STEPS for the host's choice widget. Each option has a SHORT `label` (\u22645 words, fits AskUserQuestion's label cap on Claude cowork/Claude Code) and a full `description`. For AskUserQuestion (cowork/Claude Code) pass each option as {label, description}. For ask_user_input_v0 (Claude chat/ChatGPT, string-only options) use the `description` as the option string. Use these VERBATIM, in order \u2014 do NOT re-derive, reword, or render as prose when a widget tool exists. options[0] is the artifact offer (build the lead triage board) whenever the batch is non-empty; options[1] is the enrich offer (kind:enrich_top_leads \u2014 route it to leadbay_enrich_titles scoped to the leadIds JUST shown (pass leads[].id + the pinned lens.id) with NO titles, so it runs the no-spend discovery preview; quota is only spent after the user picks titles + confirms channels on a follow-up call). When the batch is empty but the lens is still computing (computing_wishlist/computing_scores true), this carries a 'Re-pull in ~30s' option (kind:repull_computing) plus 'Refine audience' \u2014 render the widget so the user waits rather than seeing 'no leads.' null only when the batch is empty AND nothing is computing (a genuinely empty / over-narrow lens).",
@@ -16726,6 +16872,11 @@ var init_pull_leads = __esm({
16726
16872
  computingWishlist: res.computing_wishlist,
16727
16873
  computingScores: res.computing_scores
16728
16874
  });
16875
+ const lensIsEmpty = leadCount === 0 && (res.pagination?.total ?? 0) === 0;
16876
+ const emptyReason = lensIsEmpty ? await diagnoseEmptyLens(client, lensId, {
16877
+ wishlist: res.computing_wishlist,
16878
+ scores: res.computing_scores
16879
+ }) : null;
16729
16880
  return withAgentMemoryMeta(client, {
16730
16881
  lens: { id: lensId },
16731
16882
  leads: res.items.map((lead) => ({
@@ -16737,6 +16888,7 @@ var init_pull_leads = __esm({
16737
16888
  next_page: nextPage,
16738
16889
  computing_wishlist: res.computing_wishlist,
16739
16890
  computing_scores: res.computing_scores,
16891
+ empty_reason: emptyReason,
16740
16892
  next_steps: nextSteps,
16741
16893
  _meta: {
16742
16894
  region: client.region,
@@ -24036,11 +24188,54 @@ async function readExtraRefillQuota(client) {
24036
24188
  return { count: null, resets_at: null };
24037
24189
  }
24038
24190
  }
24191
+ async function readAvailablePool(client, lensId) {
24192
+ try {
24193
+ const preview = await client.request("GET", `/lenses/${lensId}/extra_refill_preview`);
24194
+ return typeof preview?.available_count === "number" ? preview.available_count : null;
24195
+ } catch {
24196
+ return null;
24197
+ }
24198
+ }
24199
+ async function readLensLeadTotal(client, lensId) {
24200
+ try {
24201
+ const page = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=1&page=0`);
24202
+ return typeof page?.pagination?.total === "number" ? page.pagination.total : null;
24203
+ } catch {
24204
+ return null;
24205
+ }
24206
+ }
24207
+ function noCandidatesReason(held, shape) {
24208
+ const { geoSentence, ...extras } = shape;
24209
+ const futile = " Extending again is futile \u2014 a refill on a lens with an empty candidate pool reports queued, consumes no quota and delivers nothing.";
24210
+ if (held === 0) {
24211
+ return {
24212
+ code: "audience_too_narrow",
24213
+ retryable: false,
24214
+ message: "This lens holds no leads and has none left to add: its criteria intersect to nothing." + geoSentence + futile + " Tell the user which criteria are in play and offer to widen the audience (leadbay_adjust_audience).",
24215
+ ...extras
24216
+ };
24217
+ }
24218
+ if (held !== null) {
24219
+ return {
24220
+ code: "no_new_leads",
24221
+ retryable: false,
24222
+ message: `Every company matching this lens has already been delivered \u2014 all ${held} of them \u2014 so there is nothing left to add.` + futile + " Tell the user; offer to widen the audience (leadbay_adjust_audience) or work the leads already in the lens (leadbay_pull_followups).",
24223
+ ...extras
24224
+ };
24225
+ }
24226
+ return {
24227
+ code: "no_candidates",
24228
+ retryable: false,
24229
+ message: "This lens has no candidates left to add." + geoSentence + futile + " Tell the user and offer to widen the audience (leadbay_adjust_audience).",
24230
+ ...extras
24231
+ };
24232
+ }
24039
24233
  var extendLens;
24040
24234
  var init_extend_lens = __esm({
24041
24235
  "../core/dist/composite/extend-lens.js"() {
24042
24236
  "use strict";
24043
24237
  init_tool_descriptions_generated();
24238
+ init_empty_lens_reason();
24044
24239
  extendLens = {
24045
24240
  name: "leadbay_extend_lens",
24046
24241
  annotations: {
@@ -24077,7 +24272,7 @@ var init_extend_lens = __esm({
24077
24272
  properties: {
24078
24273
  status: {
24079
24274
  type: "string",
24080
- description: "queued | quota_exceeded | refresh_in_progress | no_valid_seeds"
24275
+ description: "queued | no_candidates | quota_exceeded | refresh_in_progress | no_valid_seeds"
24081
24276
  },
24082
24277
  lens: {
24083
24278
  type: "object",
@@ -24092,6 +24287,35 @@ var init_extend_lens = __esm({
24092
24287
  type: "string",
24093
24288
  description: "Human-readable summary. On error statuses, this is the line to surface to the user."
24094
24289
  },
24290
+ available_count: {
24291
+ type: ["number", "null"],
24292
+ description: "How many leads a refill on this lens could still draw, read from /extra_refill_preview before queueing. 0 means the refill was NOT queued (status=no_candidates). null means the pool could not be read and the refill was queued anyway."
24293
+ },
24294
+ reason: {
24295
+ type: "object",
24296
+ description: "Only present on status=no_candidates. Same shape and `code` vocabulary as leadbay_pull_leads' empty_reason, so one routing rule covers both tools.",
24297
+ properties: {
24298
+ code: {
24299
+ type: "string",
24300
+ description: "audience_too_narrow (lens holds nothing and its criteria intersect to nothing) | no_new_leads (everything matching has already been delivered) | no_candidates (pool is empty; which of the two could not be determined)"
24301
+ },
24302
+ message: { type: "string" },
24303
+ retryable: {
24304
+ type: "boolean",
24305
+ description: "Always false here. Re-calling leadbay_extend_lens cannot change the outcome \u2014 widen the audience instead."
24306
+ },
24307
+ criteria: {
24308
+ type: "object",
24309
+ description: "The criteria in play, so the agent can name them."
24310
+ },
24311
+ narrow_locations: {
24312
+ type: "array",
24313
+ description: "Include-locations that resolved to city-scale or smaller \u2014 the usual thing to relax first.",
24314
+ items: { type: "object" }
24315
+ }
24316
+ },
24317
+ required: ["code", "message", "retryable"]
24318
+ },
24095
24319
  quota: {
24096
24320
  type: "object",
24097
24321
  description: "Only present on status=quota_exceeded. Shows the org's daily LENS_EXTRA_REFILL state.",
@@ -24105,6 +24329,21 @@ var init_extend_lens = __esm({
24105
24329
  },
24106
24330
  execute: async (client, params) => {
24107
24331
  const lensId = params.lensId ?? await client.resolveDefaultLens();
24332
+ const availableCount = await readAvailablePool(client, lensId);
24333
+ if (availableCount === 0) {
24334
+ const [shape, held] = await Promise.all([
24335
+ readAudienceShape(client, lensId),
24336
+ readLensLeadTotal(client, lensId)
24337
+ ]);
24338
+ const reason = noCandidatesReason(held, shape);
24339
+ return {
24340
+ status: "no_candidates",
24341
+ lens: { id: lensId },
24342
+ available_count: 0,
24343
+ reason,
24344
+ message: reason.message
24345
+ };
24346
+ }
24108
24347
  const body = {
24109
24348
  seed_lead_ids: params.seed_lead_ids ?? []
24110
24349
  };
@@ -24116,6 +24355,7 @@ var init_extend_lens = __esm({
24116
24355
  return {
24117
24356
  status: "queued",
24118
24357
  lens: { id: lensId },
24358
+ available_count: availableCount,
24119
24359
  accepted_seeds: res.accepted_seeds,
24120
24360
  message: "Extra refill queued. Leads stream in asynchronously \u2014 call leadbay_pull_leads in ~30s to see them."
24121
24361
  };
@@ -31730,7 +31970,7 @@ var OAUTH_BASE_URLS = {
31730
31970
  fr: "https://staging.api.leadbay.app"
31731
31971
  }
31732
31972
  };
31733
- var VERSION = "0.30.0";
31973
+ var VERSION = "0.31.1";
31734
31974
  var HELP = `
31735
31975
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
31736
31976
 
@@ -9160,8 +9160,10 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
9160
9160
 
9161
9161
  \`queued\` \u2192 \u2705 "Queued <N> extra leads on lens <id>. Pull in ~30s." Do NOT
9162
9162
  list \`accepted_seeds\`; they're internal.
9163
- \`quota_exceeded\` \u2192 render three options via your host's choice widget (\`ask_user_input_v0\` or \`AskUserQuestion\`) (smaller
9164
- count / wait until reset / upgrade).
9163
+ \`no_candidates\` \u2192 \u26D4 surface \`reason.message\`, name the criteria, offer
9164
+ \`leadbay_adjust_audience\`. Never re-call this tool on that lens.
9165
+ \`quota_exceeded\` \u2192 three options via the host's choice widget
9166
+ (smaller count / wait for reset / upgrade).
9165
9167
  \`refresh_in_progress\` \u2192 "lens is filling, retry in a minute".
9166
9168
  \`no_valid_seeds\` \u2192 silently re-call \`leadbay_seed_candidates\`, retry once.
9167
9169
 
@@ -9181,6 +9183,9 @@ Queue an additive extra-refill on a lens \u2014 more leads on the same criteria,
9181
9183
  - \`status: "quota_exceeded"\` \u2014 daily LENS_EXTRA_REFILL hit. Response carries \`quota: {used_today, resets_at}\` + a \`message\` to surface. **Render three options via your host's choice widget (\`ask_user_input_v0\` or \`AskUserQuestion\`)**: (1) smaller \`extra_count\`, (2) wait until \`resets_at\`, (3) upgrade plan (TIER1=150, TIER2=1000). Do NOT silently retry.
9182
9184
  - \`status: "refresh_in_progress"\` \u2014 a refresh or extra-refill is already running. Tell the user to wait and call \`leadbay_pull_leads\` in ~30s.
9183
9185
  - \`status: "no_valid_seeds"\` \u2014 seeds went stale. Silently re-call \`leadbay_seed_candidates\` and retry once; only surface to the user if the second attempt also fails.
9186
+ - \`status: "no_candidates"\` \u2014 **the refill was NOT queued.** The lens's candidate pool is empty, so a refill would report success, consume no quota and deliver nothing. \`reason\` carries the same \`{code, message, retryable, criteria?, narrow_locations?}\` shape \`leadbay_pull_leads\` returns in \`empty_reason\`, with \`retryable: false\`. **Stop. Do not re-call this tool on this lens** \u2014 the outcome cannot change until the audience changes. Surface \`reason.message\`, name the criteria in play, and offer \`leadbay_adjust_audience\` (or \`leadbay_pull_followups\` when \`reason.code\` is \`no_new_leads\` and the lens already holds leads).
9187
+
9188
+ **Extendability is checked before the write.** Every response carries \`available_count\` \u2014 how many leads a refill could still draw, read from the lens's own pool. \`0\` means the call was refused (\`no_candidates\`); \`null\` means the pool could not be read and the refill was queued anyway. An empty lens is NOT evidence of a broken refill: it is usually a lens that never had candidates. Reach for \`leadbay_adjust_audience\`, not another \`leadbay_extend_lens\`.
9184
9189
 
9185
9190
  WHEN TO USE: when the user has a bigger appetite than the daily lens fill delivers \u2014 they want MORE of the same kind of leads, on demand. Canonical phrasings: "I want more leads on this lens", "extend the lens", "give me a bigger batch today". The \`leadbay_extend_my_lens\` prompt is the user-facing entry point that orchestrates the whole flow.
9186
9191
 
@@ -9221,6 +9226,8 @@ Pick the row matching the response \`status\`. Seed-picking is internal; do NOT
9221
9226
  | \`quota_exceeded\` | "Upgrade plan for a higher daily limit" | (no call \u2014 direct user to contact account manager / sales) |
9222
9227
  | \`refresh_in_progress\` | "Lens is already filling \u2014 pull leads in a minute" | \`leadbay_pull_leads()\` (after a short wait) |
9223
9228
  | \`no_valid_seeds\` | (silent retry \u2014 re-call \`leadbay_seed_candidates\` then \`leadbay_extend_lens\`) | internal \u2014 only surface if the second attempt also fails |
9229
+ | \`no_candidates\` | "Widen the audience \u2014 this lens has nothing left to add" | \`leadbay_adjust_audience()\` \u2014 never \`leadbay_extend_lens\` again |
9230
+ | \`no_candidates\` (\`reason.code: no_new_leads\`) | "Work the leads already in the lens" | \`leadbay_pull_followups()\` |
9224
9231
 
9225
9232
  If nothing matches cleanly, default to "pull leads now to see what's queued" \u2014 never invent a tool that doesn't exist.
9226
9233
  `;
@@ -10924,6 +10931,11 @@ WHEN NOT TO USE: when the user has named a specific lens \u2014 pass \`lensId\`
10924
10931
 
10925
10932
  The active lens can change between calls (5-min cache + backend \`last_requested_lens\`). If a multi-step workflow depends on staying on one lens, **capture \`response.lens.id\` from the first response and pass it as the \`lensId\` argument on every subsequent Leadbay call** \u2014 including re-pulls, bulk qualifies, and research. (Field-name caveat: response nests it as \`lens.id\`; the parameter is \`lensId\`.) Re-pulling without \`lensId\` after a long-running tool may silently switch to a different lens and discard prior work.
10926
10933
 
10934
+ **EMPTY BATCH \u2014 route on \`empty_reason\`, never loop.** When \`leads\` is empty the response carries \`empty_reason: {code, message, retryable, criteria?, narrow_locations?}\`. \`retryable\` is the only field that decides what you do next:
10935
+
10936
+ - \`retryable: true\` (always \`code: "computing"\`) \u2014 the lens is still building. Say so, pull ONCE more in ~30s. Do not call it empty.
10937
+ - \`retryable: false\` \u2014 no amount of re-pulling, lens-switching or \`leadbay_extend_lens\` can produce leads on these criteria. **Stop calling tools.** Surface \`message\` to the user, name the criteria from \`criteria\` (and \`narrow_locations\` first when present \u2014 a city-scale geo scope is the usual culprit), and offer \`leadbay_adjust_audience\` to widen. A refill on a zero-candidate lens answers "queued", consumes no quota and delivers nothing, so retrying reads as progress while achieving none (product#3995).
10938
+
10927
10939
  ---
10928
10940
 
10929
10941
  ## RENDERING \u2014 markdown table, three columns, score-bar driven
@@ -18328,6 +18340,105 @@ var prepareOutreach = {
18328
18340
  }
18329
18341
  };
18330
18342
 
18343
+ // ../core/dist/composite/_empty-lens-reason.js
18344
+ var CITY_LEVEL = 7;
18345
+ function criteriaOf(filter) {
18346
+ return filter?.lens_filter?.items?.flatMap((i) => i.criteria ?? []) ?? [];
18347
+ }
18348
+ function summariseCriteria(criteria) {
18349
+ const out = {};
18350
+ for (const c of criteria) {
18351
+ if (c.type === "sector_ids") {
18352
+ const key = c.is_excluded ? "excluded_sector_ids" : "sector_ids";
18353
+ out[key] = [...out[key] ?? [], ...c.sectors ?? []];
18354
+ } else if (c.type === "location_ids") {
18355
+ const key = c.is_excluded ? "excluded_location_ids" : "location_ids";
18356
+ out[key] = [...out[key] ?? [], ...c.locations ?? []];
18357
+ } else if (c.type === "size" && !c.is_excluded) {
18358
+ out.sizes = [...out.sizes ?? [], ...c.sizes ?? []];
18359
+ }
18360
+ }
18361
+ return Object.keys(out).length > 0 ? out : void 0;
18362
+ }
18363
+ function narrowLocationsOf(filter, criteria) {
18364
+ const included = new Set(criteria.filter((c) => c.type === "location_ids" && !c.is_excluded).flatMap((c) => c.locations ?? []));
18365
+ if (included.size === 0)
18366
+ return [];
18367
+ const results = filter?.locations?.results ?? [];
18368
+ return results.filter((r) => typeof r.id === "string" && included.has(r.id) && typeof r.level === "number" && r.level >= CITY_LEVEL).map((r) => ({
18369
+ id: r.id,
18370
+ name: typeof r.name === "string" ? r.name : "",
18371
+ level: r.level
18372
+ }));
18373
+ }
18374
+ function narrowGeoSentence(narrow) {
18375
+ const names = narrow.map((n) => n.name).filter(Boolean);
18376
+ if (names.length === 0)
18377
+ return "";
18378
+ return ` Its geography is pinned to ${names.join(", ")} \u2014 a city-scale area or smaller, which on an empty lens is almost always the criterion to relax first.`;
18379
+ }
18380
+ async function readAudienceShape(client, lensId) {
18381
+ let filter = null;
18382
+ try {
18383
+ filter = await client.request("GET", `/lenses/${lensId}/filter`);
18384
+ } catch {
18385
+ }
18386
+ const criteria = criteriaOf(filter);
18387
+ const summary = summariseCriteria(criteria);
18388
+ const narrow = narrowLocationsOf(filter, criteria);
18389
+ return {
18390
+ ...summary ? { criteria: summary } : {},
18391
+ ...narrow.length > 0 ? { narrow_locations: narrow } : {},
18392
+ geoSentence: narrowGeoSentence(narrow)
18393
+ };
18394
+ }
18395
+ async function diagnoseEmptyLens(client, lensId, computing) {
18396
+ if (computing.wishlist || computing.scores) {
18397
+ return {
18398
+ code: "computing",
18399
+ retryable: true,
18400
+ message: "This lens is still computing its leads. Pull again in ~30s \u2014 do NOT report it as empty yet."
18401
+ };
18402
+ }
18403
+ let row;
18404
+ try {
18405
+ const lenses = await client.request("GET", "/lenses");
18406
+ row = lenses.find((l) => String(l.id) === String(lensId));
18407
+ } catch {
18408
+ }
18409
+ const { geoSentence: geo, ...extras } = await readAudienceShape(client, lensId);
18410
+ const summary = extras.criteria;
18411
+ if (row?.not_enough_lead_candidates) {
18412
+ return {
18413
+ code: "no_candidates",
18414
+ retryable: false,
18415
+ message: "This lens's criteria match no companies in the database, so it cannot fill." + geo + " Tell the user and offer to widen the audience (leadbay_adjust_audience) \u2014 extending or re-pulling will not help.",
18416
+ ...extras
18417
+ };
18418
+ }
18419
+ if (row?.not_enough_new_leads) {
18420
+ return {
18421
+ code: "no_new_leads",
18422
+ retryable: false,
18423
+ message: "Every company matching this lens has already been delivered \u2014 there are no NEW leads left on these criteria. Tell the user; offer to widen the audience (leadbay_adjust_audience) or work the existing leads via leadbay_pull_followups.",
18424
+ ...extras
18425
+ };
18426
+ }
18427
+ if (summary) {
18428
+ return {
18429
+ code: "audience_too_narrow",
18430
+ retryable: false,
18431
+ message: "This lens is finished computing and holds zero leads: its criteria intersect to nothing." + geo + " Tell the user which criteria are in play and offer to widen the audience (leadbay_adjust_audience). Do NOT call leadbay_extend_lens \u2014 a refill on a zero-candidate lens reports queued, consumes no quota, and delivers nothing.",
18432
+ ...extras
18433
+ };
18434
+ }
18435
+ return {
18436
+ code: "unknown",
18437
+ retryable: false,
18438
+ message: "This lens is finished computing and holds zero leads, and carries no audience criteria that would explain it. Report this to the user rather than retrying; leadbay_report_friction is the way to flag it to the Leadbay team."
18439
+ };
18440
+ }
18441
+
18331
18442
  // ../core/dist/composite/pull-leads.js
18332
18443
  function normalizeLinkedinPage3(v) {
18333
18444
  if (v == null)
@@ -18467,6 +18578,34 @@ var pullLeads = {
18467
18578
  type: "boolean",
18468
18579
  description: "True if scoring is still running."
18469
18580
  },
18581
+ empty_reason: {
18582
+ type: ["object", "null"],
18583
+ description: "Why this LENS holds zero leads. null whenever leads were returned, and null when this page is empty only because it is past the end of a non-empty lens. `retryable` is the field to route on: true ONLY on code=computing (pull again in ~30s). On every other code re-pulling and leadbay_extend_lens are both futile \u2014 a refill on a zero-candidate lens answers 'queued', consumes no quota and delivers nothing \u2014 so surface `message` to the user and offer leadbay_adjust_audience instead of retrying.",
18584
+ properties: {
18585
+ code: {
18586
+ type: "string",
18587
+ description: "computing | no_candidates | no_new_leads | audience_too_narrow | unknown"
18588
+ },
18589
+ message: {
18590
+ type: "string",
18591
+ description: "The line to surface to the user."
18592
+ },
18593
+ retryable: {
18594
+ type: "boolean",
18595
+ description: "True only while the lens is still computing. False means no amount of re-pulling or extending can produce leads."
18596
+ },
18597
+ criteria: {
18598
+ type: "object",
18599
+ description: "The lens criteria in play \u2014 what the user would have to relax. Present when the lens carries any."
18600
+ },
18601
+ narrow_locations: {
18602
+ type: "array",
18603
+ description: "Include-locations that resolved to a city-scale area or smaller ({id, name, level}). On an empty lens, name these first: this is the fingerprint of a whole-country location that fell through to a same-named village (product#3951).",
18604
+ items: { type: "object" }
18605
+ }
18606
+ },
18607
+ required: ["code", "message", "retryable"]
18608
+ },
18470
18609
  next_steps: {
18471
18610
  type: ["object", "null"],
18472
18611
  description: "Ready-made NEXT STEPS for the host's choice widget. Each option has a SHORT `label` (\u22645 words, fits AskUserQuestion's label cap on Claude cowork/Claude Code) and a full `description`. For AskUserQuestion (cowork/Claude Code) pass each option as {label, description}. For ask_user_input_v0 (Claude chat/ChatGPT, string-only options) use the `description` as the option string. Use these VERBATIM, in order \u2014 do NOT re-derive, reword, or render as prose when a widget tool exists. options[0] is the artifact offer (build the lead triage board) whenever the batch is non-empty; options[1] is the enrich offer (kind:enrich_top_leads \u2014 route it to leadbay_enrich_titles scoped to the leadIds JUST shown (pass leads[].id + the pinned lens.id) with NO titles, so it runs the no-spend discovery preview; quota is only spent after the user picks titles + confirms channels on a follow-up call). When the batch is empty but the lens is still computing (computing_wishlist/computing_scores true), this carries a 'Re-pull in ~30s' option (kind:repull_computing) plus 'Refine audience' \u2014 render the widget so the user waits rather than seeing 'no leads.' null only when the batch is empty AND nothing is computing (a genuinely empty / over-narrow lens).",
@@ -18567,6 +18706,11 @@ var pullLeads = {
18567
18706
  computingWishlist: res.computing_wishlist,
18568
18707
  computingScores: res.computing_scores
18569
18708
  });
18709
+ const lensIsEmpty = leadCount === 0 && (res.pagination?.total ?? 0) === 0;
18710
+ const emptyReason = lensIsEmpty ? await diagnoseEmptyLens(client, lensId, {
18711
+ wishlist: res.computing_wishlist,
18712
+ scores: res.computing_scores
18713
+ }) : null;
18570
18714
  return withAgentMemoryMeta(client, {
18571
18715
  lens: { id: lensId },
18572
18716
  leads: res.items.map((lead) => ({
@@ -18578,6 +18722,7 @@ var pullLeads = {
18578
18722
  next_page: nextPage,
18579
18723
  computing_wishlist: res.computing_wishlist,
18580
18724
  computing_scores: res.computing_scores,
18725
+ empty_reason: emptyReason,
18581
18726
  next_steps: nextSteps,
18582
18727
  _meta: {
18583
18728
  region: client.region,
@@ -25006,6 +25151,48 @@ async function readExtraRefillQuota(client) {
25006
25151
  return { count: null, resets_at: null };
25007
25152
  }
25008
25153
  }
25154
+ async function readAvailablePool(client, lensId) {
25155
+ try {
25156
+ const preview = await client.request("GET", `/lenses/${lensId}/extra_refill_preview`);
25157
+ return typeof preview?.available_count === "number" ? preview.available_count : null;
25158
+ } catch {
25159
+ return null;
25160
+ }
25161
+ }
25162
+ async function readLensLeadTotal(client, lensId) {
25163
+ try {
25164
+ const page = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=1&page=0`);
25165
+ return typeof page?.pagination?.total === "number" ? page.pagination.total : null;
25166
+ } catch {
25167
+ return null;
25168
+ }
25169
+ }
25170
+ function noCandidatesReason(held, shape) {
25171
+ const { geoSentence, ...extras } = shape;
25172
+ const futile = " Extending again is futile \u2014 a refill on a lens with an empty candidate pool reports queued, consumes no quota and delivers nothing.";
25173
+ if (held === 0) {
25174
+ return {
25175
+ code: "audience_too_narrow",
25176
+ retryable: false,
25177
+ message: "This lens holds no leads and has none left to add: its criteria intersect to nothing." + geoSentence + futile + " Tell the user which criteria are in play and offer to widen the audience (leadbay_adjust_audience).",
25178
+ ...extras
25179
+ };
25180
+ }
25181
+ if (held !== null) {
25182
+ return {
25183
+ code: "no_new_leads",
25184
+ retryable: false,
25185
+ message: `Every company matching this lens has already been delivered \u2014 all ${held} of them \u2014 so there is nothing left to add.` + futile + " Tell the user; offer to widen the audience (leadbay_adjust_audience) or work the leads already in the lens (leadbay_pull_followups).",
25186
+ ...extras
25187
+ };
25188
+ }
25189
+ return {
25190
+ code: "no_candidates",
25191
+ retryable: false,
25192
+ message: "This lens has no candidates left to add." + geoSentence + futile + " Tell the user and offer to widen the audience (leadbay_adjust_audience).",
25193
+ ...extras
25194
+ };
25195
+ }
25009
25196
  var extendLens = {
25010
25197
  name: "leadbay_extend_lens",
25011
25198
  annotations: {
@@ -25042,7 +25229,7 @@ var extendLens = {
25042
25229
  properties: {
25043
25230
  status: {
25044
25231
  type: "string",
25045
- description: "queued | quota_exceeded | refresh_in_progress | no_valid_seeds"
25232
+ description: "queued | no_candidates | quota_exceeded | refresh_in_progress | no_valid_seeds"
25046
25233
  },
25047
25234
  lens: {
25048
25235
  type: "object",
@@ -25057,6 +25244,35 @@ var extendLens = {
25057
25244
  type: "string",
25058
25245
  description: "Human-readable summary. On error statuses, this is the line to surface to the user."
25059
25246
  },
25247
+ available_count: {
25248
+ type: ["number", "null"],
25249
+ description: "How many leads a refill on this lens could still draw, read from /extra_refill_preview before queueing. 0 means the refill was NOT queued (status=no_candidates). null means the pool could not be read and the refill was queued anyway."
25250
+ },
25251
+ reason: {
25252
+ type: "object",
25253
+ description: "Only present on status=no_candidates. Same shape and `code` vocabulary as leadbay_pull_leads' empty_reason, so one routing rule covers both tools.",
25254
+ properties: {
25255
+ code: {
25256
+ type: "string",
25257
+ description: "audience_too_narrow (lens holds nothing and its criteria intersect to nothing) | no_new_leads (everything matching has already been delivered) | no_candidates (pool is empty; which of the two could not be determined)"
25258
+ },
25259
+ message: { type: "string" },
25260
+ retryable: {
25261
+ type: "boolean",
25262
+ description: "Always false here. Re-calling leadbay_extend_lens cannot change the outcome \u2014 widen the audience instead."
25263
+ },
25264
+ criteria: {
25265
+ type: "object",
25266
+ description: "The criteria in play, so the agent can name them."
25267
+ },
25268
+ narrow_locations: {
25269
+ type: "array",
25270
+ description: "Include-locations that resolved to city-scale or smaller \u2014 the usual thing to relax first.",
25271
+ items: { type: "object" }
25272
+ }
25273
+ },
25274
+ required: ["code", "message", "retryable"]
25275
+ },
25060
25276
  quota: {
25061
25277
  type: "object",
25062
25278
  description: "Only present on status=quota_exceeded. Shows the org's daily LENS_EXTRA_REFILL state.",
@@ -25070,6 +25286,21 @@ var extendLens = {
25070
25286
  },
25071
25287
  execute: async (client, params) => {
25072
25288
  const lensId = params.lensId ?? await client.resolveDefaultLens();
25289
+ const availableCount = await readAvailablePool(client, lensId);
25290
+ if (availableCount === 0) {
25291
+ const [shape, held] = await Promise.all([
25292
+ readAudienceShape(client, lensId),
25293
+ readLensLeadTotal(client, lensId)
25294
+ ]);
25295
+ const reason = noCandidatesReason(held, shape);
25296
+ return {
25297
+ status: "no_candidates",
25298
+ lens: { id: lensId },
25299
+ available_count: 0,
25300
+ reason,
25301
+ message: reason.message
25302
+ };
25303
+ }
25073
25304
  const body = {
25074
25305
  seed_lead_ids: params.seed_lead_ids ?? []
25075
25306
  };
@@ -25081,6 +25312,7 @@ var extendLens = {
25081
25312
  return {
25082
25313
  status: "queued",
25083
25314
  lens: { id: lensId },
25315
+ available_count: availableCount,
25084
25316
  accepted_seeds: res.accepted_seeds,
25085
25317
  message: "Extra refill queued. Leads stream in asynchronously \u2014 call leadbay_pull_leads in ~30s to see them."
25086
25318
  };
@@ -28307,7 +28539,7 @@ function parseWriteEnv(env = process.env) {
28307
28539
  }
28308
28540
 
28309
28541
  // src/http-server.ts
28310
- var VERSION = true ? "0.30.0" : "0.0.0-dev";
28542
+ var VERSION = true ? "0.31.1" : "0.0.0-dev";
28311
28543
  var PORT = Number(process.env.PORT ?? 8080);
28312
28544
  var HOST = process.env.HOST ?? "0.0.0.0";
28313
28545
  var logger = {
@@ -1804,7 +1804,7 @@ var init_installer_gui = __esm({
1804
1804
  init_install_dxt();
1805
1805
  init_install_shared();
1806
1806
  init_oauth();
1807
- VERSION = true ? "0.30.0" : "0.0.0-dev";
1807
+ VERSION = true ? "0.31.1" : "0.0.0-dev";
1808
1808
  MESSAGES = {
1809
1809
  en: {
1810
1810
  installer: {
@@ -1067,7 +1067,7 @@ async function oauthLogin(opts) {
1067
1067
  }
1068
1068
 
1069
1069
  // installer/installer-gui.ts
1070
- var VERSION = true ? "0.30.0" : "0.0.0-dev";
1070
+ var VERSION = true ? "0.31.1" : "0.0.0-dev";
1071
1071
  var MESSAGES = {
1072
1072
  en: {
1073
1073
  installer: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leadbay/mcp",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "mcpName": "io.github.leadbay/leadbay-mcp",
5
5
  "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.",
6
6
  "type": "module",