@leadbay/mcp 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/bin.js +2842 -244
- package/dist/http-server.js +2690 -132
- package/dist/installer-electron.js +1 -1
- package/dist/installer-gui.js +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -30,6 +30,13 @@ function makeCancelledError(method, url) {
|
|
|
30
30
|
err.code = "CANCELLED";
|
|
31
31
|
return err;
|
|
32
32
|
}
|
|
33
|
+
function timeoutError(what, timeoutMs) {
|
|
34
|
+
const err = new Error(what);
|
|
35
|
+
err.code = "TIMEOUT";
|
|
36
|
+
if (timeoutMs !== void 0)
|
|
37
|
+
err.timeout_ms = timeoutMs;
|
|
38
|
+
return err;
|
|
39
|
+
}
|
|
33
40
|
function httpsRequest(method, url, headers, body, timeoutMs, signal) {
|
|
34
41
|
const deadlineMs = timeoutMs ?? defaultTimeoutMs();
|
|
35
42
|
const abortSignal = signal ?? requestSignalStore.getStore();
|
|
@@ -58,7 +65,12 @@ function httpsRequest(method, url, headers, body, timeoutMs, signal) {
|
|
|
58
65
|
port: 443,
|
|
59
66
|
path: parsed.pathname + parsed.search,
|
|
60
67
|
method,
|
|
61
|
-
headers: reqHeaders
|
|
68
|
+
headers: reqHeaders,
|
|
69
|
+
// Node aborts the socket and emits an AbortError on `error`, which the
|
|
70
|
+
// handler below rejects with. Without this a cancelled tool call sat on
|
|
71
|
+
// an in-flight GET until the server answered — the polling loop cannot
|
|
72
|
+
// honour its advertised <=2s exit while blocked inside one.
|
|
73
|
+
signal
|
|
62
74
|
}, (res) => {
|
|
63
75
|
const chunks = [];
|
|
64
76
|
res.on("data", (chunk) => chunks.push(chunk));
|
|
@@ -360,18 +372,70 @@ var init_client = __esm({
|
|
|
360
372
|
get _semaphoreState() {
|
|
361
373
|
return { active: this.activeRequests, queued: this.waitQueue.length };
|
|
362
374
|
}
|
|
363
|
-
|
|
375
|
+
// `signal` makes a QUEUED acquisition abortable. Without it a cancelled call
|
|
376
|
+
// that arrived when all MAX_CONCURRENT slots were busy could not observe the
|
|
377
|
+
// abort until an unrelated request finished — the signal was only forwarded
|
|
378
|
+
// to the socket, which this call had not reached yet. Against slow or stalled
|
|
379
|
+
// peers that stranded the caller well past the <=2s exit the delivery tools
|
|
380
|
+
// advertise.
|
|
381
|
+
// `deadlineAt` is an ABSOLUTE epoch-ms bound covering the queue wait itself.
|
|
382
|
+
// Without it a bounded call could still be stranded here without limit: the
|
|
383
|
+
// deadline was only handed to httpsRequest, which does not start until this
|
|
384
|
+
// resolves, so five slow peers let even `wait_seconds: 1` run unbounded. The
|
|
385
|
+
// wait a caller asked for is wall-clock, not socket time.
|
|
386
|
+
// `budgetMs` is the DURATION `deadlineAt` was derived from. Only the caller
|
|
387
|
+
// knows it — an absolute deadline cannot be turned back into "how long did we
|
|
388
|
+
// agree to wait" here — and the timeout error needs it, or the envelope
|
|
389
|
+
// reports the 600,000ms default instead of the seconds actually granted.
|
|
390
|
+
async acquireSemaphore(signal, deadlineAt, budgetMs) {
|
|
391
|
+
if (signal?.aborted)
|
|
392
|
+
throw this.cancelledBeforeSendError();
|
|
393
|
+
if (deadlineAt !== void 0 && Date.now() >= deadlineAt) {
|
|
394
|
+
throw timeoutError("Request deadline expired before a request slot was free", budgetMs);
|
|
395
|
+
}
|
|
364
396
|
if (this.activeRequests < MAX_CONCURRENT) {
|
|
365
397
|
this.activeRequests++;
|
|
366
398
|
return;
|
|
367
399
|
}
|
|
368
|
-
return new Promise((resolve) => {
|
|
369
|
-
|
|
400
|
+
return new Promise((resolve, reject) => {
|
|
401
|
+
let timer;
|
|
402
|
+
const waiter = () => {
|
|
403
|
+
cleanup();
|
|
370
404
|
this.activeRequests++;
|
|
371
405
|
resolve();
|
|
372
|
-
}
|
|
406
|
+
};
|
|
407
|
+
const drop = () => {
|
|
408
|
+
const i = this.waitQueue.indexOf(waiter);
|
|
409
|
+
if (i !== -1)
|
|
410
|
+
this.waitQueue.splice(i, 1);
|
|
411
|
+
cleanup();
|
|
412
|
+
};
|
|
413
|
+
const onAbort = () => {
|
|
414
|
+
drop();
|
|
415
|
+
reject(this.cancelledBeforeSendError());
|
|
416
|
+
};
|
|
417
|
+
const onDeadline = () => {
|
|
418
|
+
drop();
|
|
419
|
+
reject(timeoutError("Request deadline expired while queued for a request slot", budgetMs));
|
|
420
|
+
};
|
|
421
|
+
const cleanup = () => {
|
|
422
|
+
signal?.removeEventListener("abort", onAbort);
|
|
423
|
+
if (timer !== void 0)
|
|
424
|
+
clearTimeout(timer);
|
|
425
|
+
};
|
|
426
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
427
|
+
if (deadlineAt !== void 0) {
|
|
428
|
+
timer = setTimeout(onDeadline, Math.max(deadlineAt - Date.now(), 0));
|
|
429
|
+
timer.unref?.();
|
|
430
|
+
}
|
|
431
|
+
this.waitQueue.push(waiter);
|
|
373
432
|
});
|
|
374
433
|
}
|
|
434
|
+
// Cancelled while queued — nothing was ever put on the wire, which is what
|
|
435
|
+
// makes this safe to report as "not sent" even for a write.
|
|
436
|
+
cancelledBeforeSendError() {
|
|
437
|
+
return this.makeError("REQUEST_CANCELLED", "The request was cancelled before it was sent.", "Re-call the tool if you still want the result \u2014 nothing reached the API, so nothing was charged.");
|
|
438
|
+
}
|
|
375
439
|
releaseSemaphore() {
|
|
376
440
|
this.activeRequests--;
|
|
377
441
|
const next = this.waitQueue.shift();
|
|
@@ -413,16 +477,52 @@ var init_client = __esm({
|
|
|
413
477
|
// The 250ms backoff releases the concurrency slot first (release → sleep →
|
|
414
478
|
// re-acquire) so a wave of 401s doesn't pin all MAX_CONCURRENT slots in
|
|
415
479
|
// setTimeout and stall the queue.
|
|
416
|
-
httpsRequestWithRetry = async (method, url, headers, body, timeoutMs) => {
|
|
417
|
-
const
|
|
480
|
+
httpsRequestWithRetry = async (method, url, headers, body, timeoutMs, signal, held, totalDeadlineAt) => {
|
|
481
|
+
const perAttemptOptOut = timeoutMs !== void 0 && timeoutMs <= 0;
|
|
482
|
+
const retryStartedAt = Date.now();
|
|
483
|
+
const phaseBudget = () => {
|
|
484
|
+
const now = Date.now();
|
|
485
|
+
const perAttempt = timeoutMs !== void 0 && !perAttemptOptOut ? now + timeoutMs : void 0;
|
|
486
|
+
const deadline = totalDeadlineAt === void 0 ? perAttempt : perAttempt === void 0 ? totalDeadlineAt : Math.min(perAttempt, totalDeadlineAt);
|
|
487
|
+
if (deadline === void 0)
|
|
488
|
+
return perAttemptOptOut ? timeoutMs : void 0;
|
|
489
|
+
const left = deadline - now;
|
|
490
|
+
if (left <= 0) {
|
|
491
|
+
throw timeoutError(`Request deadline expired: ${method} ${url}`, Math.max(0, deadline - retryStartedAt));
|
|
492
|
+
}
|
|
493
|
+
return left;
|
|
494
|
+
};
|
|
495
|
+
const phaseDeadline = () => {
|
|
496
|
+
const b = phaseBudget();
|
|
497
|
+
if (b === void 0 || b <= 0)
|
|
498
|
+
return void 0;
|
|
499
|
+
return Date.now() + b;
|
|
500
|
+
};
|
|
501
|
+
const res = await httpsRequest(method, url, headers, body, phaseBudget(), signal);
|
|
418
502
|
if (res.status === 401 && retriesOn401(method)) {
|
|
503
|
+
if (signal?.aborted)
|
|
504
|
+
return res;
|
|
419
505
|
this.releaseSemaphore();
|
|
506
|
+
if (held)
|
|
507
|
+
held.value = false;
|
|
420
508
|
try {
|
|
421
|
-
await new Promise((
|
|
509
|
+
await new Promise((resolve) => {
|
|
510
|
+
const t = setTimeout(done, 250);
|
|
511
|
+
function done() {
|
|
512
|
+
clearTimeout(t);
|
|
513
|
+
signal?.removeEventListener("abort", done);
|
|
514
|
+
resolve();
|
|
515
|
+
}
|
|
516
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
517
|
+
});
|
|
422
518
|
} finally {
|
|
423
|
-
await this.acquireSemaphore();
|
|
519
|
+
await this.acquireSemaphore(held ? signal : void 0, phaseDeadline(), phaseBudget());
|
|
520
|
+
if (held)
|
|
521
|
+
held.value = true;
|
|
424
522
|
}
|
|
425
|
-
|
|
523
|
+
if (signal?.aborted)
|
|
524
|
+
return res;
|
|
525
|
+
return httpsRequest(method, url, headers, body, phaseBudget(), signal);
|
|
426
526
|
}
|
|
427
527
|
return res;
|
|
428
528
|
};
|
|
@@ -435,8 +535,43 @@ var init_client = __esm({
|
|
|
435
535
|
}
|
|
436
536
|
const retryOn401 = opts?.retryOn401 !== false;
|
|
437
537
|
const retriedOn401 = retryOn401 && retriesOn401(method);
|
|
438
|
-
|
|
538
|
+
const held = { value: true };
|
|
539
|
+
const startedAt = Date.now();
|
|
540
|
+
const totalDeadlineAt = opts?.totalTimeoutMs !== void 0 ? startedAt + opts.totalTimeoutMs : void 0;
|
|
541
|
+
const perAttemptOptOut = opts?.timeoutMs !== void 0 && opts.timeoutMs <= 0;
|
|
542
|
+
const phaseDeadlineAt = () => {
|
|
543
|
+
const now = Date.now();
|
|
544
|
+
const perAttempt = opts?.timeoutMs !== void 0 && !perAttemptOptOut ? now + opts.timeoutMs : void 0;
|
|
545
|
+
if (totalDeadlineAt === void 0)
|
|
546
|
+
return perAttempt;
|
|
547
|
+
if (perAttempt === void 0)
|
|
548
|
+
return totalDeadlineAt;
|
|
549
|
+
return Math.min(perAttempt, totalDeadlineAt);
|
|
550
|
+
};
|
|
551
|
+
const grantedBudget = () => {
|
|
552
|
+
const bounds = [opts?.timeoutMs, opts?.totalTimeoutMs].filter((n) => typeof n === "number" && n > 0);
|
|
553
|
+
return bounds.length ? Math.min(...bounds) : void 0;
|
|
554
|
+
};
|
|
555
|
+
const remainingBudget = () => {
|
|
556
|
+
const deadline = phaseDeadlineAt();
|
|
557
|
+
if (deadline === void 0)
|
|
558
|
+
return perAttemptOptOut ? opts?.timeoutMs : void 0;
|
|
559
|
+
const left = deadline - Date.now();
|
|
560
|
+
if (left <= 0) {
|
|
561
|
+
throw timeoutError(`Request deadline expired: ${method} ${path}`, grantedBudget());
|
|
562
|
+
}
|
|
563
|
+
return left;
|
|
564
|
+
};
|
|
565
|
+
const queueDeadlineAt = () => totalDeadlineAt;
|
|
566
|
+
const queueBudget = () => typeof opts?.totalTimeoutMs === "number" && opts.totalTimeoutMs > 0 ? opts.totalTimeoutMs : void 0;
|
|
567
|
+
try {
|
|
568
|
+
await this.acquireSemaphore(opts?.signal ?? opts?.preSendSignal, queueDeadlineAt(), queueBudget());
|
|
569
|
+
} catch (e) {
|
|
570
|
+
throw this.mapTransportError(e, `${method} ${path}`);
|
|
571
|
+
}
|
|
439
572
|
try {
|
|
573
|
+
if (opts?.preSendSignal?.aborted)
|
|
574
|
+
throw this.cancelledBeforeSendError();
|
|
440
575
|
const url = `${this._baseUrl}${API_PREFIX}${path}`;
|
|
441
576
|
const headers = {
|
|
442
577
|
Authorization: `Bearer ${this.token}`
|
|
@@ -444,7 +579,17 @@ var init_client = __esm({
|
|
|
444
579
|
if (body) {
|
|
445
580
|
headers["Content-Type"] = "application/json";
|
|
446
581
|
}
|
|
447
|
-
const
|
|
582
|
+
const payload = body ? JSON.stringify(body) : void 0;
|
|
583
|
+
const res = retryOn401 ? await this.httpsRequestWithRetry(method, url, headers, payload, opts?.timeoutMs, opts?.signal, held, totalDeadlineAt) : await httpsRequest(
|
|
584
|
+
method,
|
|
585
|
+
url,
|
|
586
|
+
headers,
|
|
587
|
+
payload,
|
|
588
|
+
// What is LEFT after queueing, not the original budget — otherwise
|
|
589
|
+
// the queue wait and the socket wait each get the full allowance.
|
|
590
|
+
remainingBudget(),
|
|
591
|
+
opts?.signal
|
|
592
|
+
);
|
|
448
593
|
this._lastMeta = {
|
|
449
594
|
region: this._region,
|
|
450
595
|
endpoint: `${method} ${path}`,
|
|
@@ -461,7 +606,8 @@ var init_client = __esm({
|
|
|
461
606
|
} catch (e) {
|
|
462
607
|
throw this.mapTransportError(e, `${method} ${path}`);
|
|
463
608
|
} finally {
|
|
464
|
-
|
|
609
|
+
if (held.value)
|
|
610
|
+
this.releaseSemaphore();
|
|
465
611
|
}
|
|
466
612
|
}
|
|
467
613
|
async requestVoid(method, path, body) {
|
|
@@ -950,6 +1096,7 @@ var init_composite_file_names = __esm({
|
|
|
950
1096
|
"leadbay_delete_custom_field",
|
|
951
1097
|
"leadbay_enrich_titles",
|
|
952
1098
|
"leadbay_extend_lens",
|
|
1099
|
+
"leadbay_find_new_leads",
|
|
953
1100
|
"leadbay_followups_map",
|
|
954
1101
|
"leadbay_get_lead_custom_fields",
|
|
955
1102
|
"leadbay_get_qualification_questions",
|
|
@@ -957,12 +1104,14 @@ var init_composite_file_names = __esm({
|
|
|
957
1104
|
"leadbay_import_and_qualify",
|
|
958
1105
|
"leadbay_import_leads",
|
|
959
1106
|
"leadbay_import_status",
|
|
1107
|
+
"leadbay_lead_job_status",
|
|
960
1108
|
"leadbay_list_campaigns",
|
|
961
1109
|
"leadbay_my_lenses",
|
|
962
1110
|
"leadbay_new_lens",
|
|
963
1111
|
"leadbay_prepare_outreach",
|
|
964
1112
|
"leadbay_pull_followups",
|
|
965
1113
|
"leadbay_pull_leads",
|
|
1114
|
+
"leadbay_qualify_leads",
|
|
966
1115
|
"leadbay_qualify_status",
|
|
967
1116
|
"leadbay_recall_ordered_titles",
|
|
968
1117
|
"leadbay_refine_prompt",
|
|
@@ -1215,12 +1364,12 @@ var init_ws_client = __esm({
|
|
|
1215
1364
|
this.pingTimer = setInterval(() => this.sendPing(), PING_INTERVAL_MS);
|
|
1216
1365
|
});
|
|
1217
1366
|
ws.addEventListener("message", (ev) => {
|
|
1218
|
-
const
|
|
1219
|
-
if (!
|
|
1367
|
+
const text2 = typeof ev.data === "string" ? ev.data : String(ev.data ?? "");
|
|
1368
|
+
if (!text2)
|
|
1220
1369
|
return;
|
|
1221
1370
|
let msg;
|
|
1222
1371
|
try {
|
|
1223
|
-
msg = JSON.parse(
|
|
1372
|
+
msg = JSON.parse(text2);
|
|
1224
1373
|
} catch {
|
|
1225
1374
|
this.logger?.warn?.("notifications.ws non_json_frame");
|
|
1226
1375
|
return;
|
|
@@ -1300,7 +1449,7 @@ var init_notifications = __esm({
|
|
|
1300
1449
|
});
|
|
1301
1450
|
|
|
1302
1451
|
// ../core/dist/tool-descriptions.generated.js
|
|
1303
|
-
var leadbay_account_history, leadbay_account_status, leadbay_acknowledge_notification, leadbay_add_contact, leadbay_add_leads_to_campaign, leadbay_add_note, leadbay_adjust_audience, leadbay_answer_clarification, leadbay_artifact_kit, leadbay_bulk_enrich_status, leadbay_bulk_qualify_leads, leadbay_campaign_call_sheet, leadbay_campaign_progression, leadbay_clear_selection, leadbay_clear_user_prompt, leadbay_create_campaign, leadbay_create_custom_field, leadbay_create_lens, leadbay_create_lens_draft, leadbay_create_topup_link, leadbay_delete_custom_field, leadbay_deselect_leads, leadbay_discover_leads, leadbay_dislike_lead, leadbay_dismiss_clarification, leadbay_enrich_contacts, leadbay_enrich_titles, leadbay_extend_lens, leadbay_followups_map, leadbay_get_clarification, leadbay_get_contacts, leadbay_get_enrichment_job_titles, leadbay_get_epilogue_responses, leadbay_get_lead_activities, leadbay_get_lead_custom_fields, leadbay_get_lead_notes, leadbay_get_lead_profile, leadbay_get_lens_filter, leadbay_get_lens_scoring, leadbay_get_prospecting_actions, leadbay_get_qualification_questions, leadbay_get_quota, leadbay_get_selection_ids, leadbay_get_taste_profile, leadbay_get_user_prompt, leadbay_get_web_fetch, leadbay_getting_started, leadbay_import_and_qualify, leadbay_import_leads, leadbay_import_status, leadbay_launch_bulk_enrichment, leadbay_like_lead, leadbay_list_campaigns, leadbay_list_lenses, leadbay_list_locations, leadbay_list_mappable_fields, leadbay_list_sectors, leadbay_login, leadbay_my_lenses, leadbay_new_lens, leadbay_open_billing_portal, leadbay_pick_clarification, leadbay_pin_contact, leadbay_prepare_outreach, leadbay_preview_bulk_enrichment, leadbay_promote_lens, leadbay_pull_followups, leadbay_pull_leads, leadbay_qualify_lead, leadbay_qualify_status, leadbay_recall_ordered_titles, leadbay_refine_prompt, leadbay_remove_contact, leadbay_remove_epilogue, leadbay_remove_leads_from_campaign, leadbay_remove_pushback, leadbay_report_friction, leadbay_report_outreach, leadbay_research_lead_by_id, leadbay_research_lead_by_name_fuzzy, leadbay_resolve_import_rows, leadbay_scan_portfolio_signals, leadbay_seed_candidates, leadbay_select_leads, leadbay_send_feedback, leadbay_set_active_lens, leadbay_set_epilogue_status, leadbay_set_lead_status, leadbay_set_pushback, leadbay_set_qualification_questions, leadbay_set_telemetry, leadbay_set_user_prompt, leadbay_team_activity, leadbay_tour_plan, leadbay_unpin_contact, leadbay_update_contact, leadbay_update_custom_field, leadbay_update_lens, leadbay_update_lens_filter, NO_COMMERCE_TOOL_DESCRIPTIONS;
|
|
1452
|
+
var leadbay_account_history, leadbay_account_status, leadbay_acknowledge_notification, leadbay_add_contact, leadbay_add_leads_to_campaign, leadbay_add_note, leadbay_adjust_audience, leadbay_answer_clarification, leadbay_artifact_kit, leadbay_bulk_enrich_status, leadbay_bulk_qualify_leads, leadbay_campaign_call_sheet, leadbay_campaign_progression, leadbay_clear_selection, leadbay_clear_user_prompt, leadbay_create_campaign, leadbay_create_custom_field, leadbay_create_lens, leadbay_create_lens_draft, leadbay_create_topup_link, leadbay_delete_custom_field, leadbay_deselect_leads, leadbay_discover_leads, leadbay_dislike_lead, leadbay_dismiss_clarification, leadbay_enrich_contacts, leadbay_enrich_titles, leadbay_extend_lens, leadbay_find_new_leads, leadbay_followups_map, leadbay_get_clarification, leadbay_get_contacts, leadbay_get_enrichment_job_titles, leadbay_get_epilogue_responses, leadbay_get_lead_activities, leadbay_get_lead_custom_fields, leadbay_get_lead_notes, leadbay_get_lead_profile, leadbay_get_lens_filter, leadbay_get_lens_scoring, leadbay_get_prospecting_actions, leadbay_get_qualification_questions, leadbay_get_quota, leadbay_get_selection_ids, leadbay_get_taste_profile, leadbay_get_user_prompt, leadbay_get_web_fetch, leadbay_getting_started, leadbay_import_and_qualify, leadbay_import_leads, leadbay_import_status, leadbay_launch_bulk_enrichment, leadbay_lead_job_status, leadbay_like_lead, leadbay_list_campaigns, leadbay_list_lenses, leadbay_list_locations, leadbay_list_mappable_fields, leadbay_list_sectors, leadbay_login, leadbay_my_lenses, leadbay_new_lens, leadbay_open_billing_portal, leadbay_pick_clarification, leadbay_pin_contact, leadbay_prepare_outreach, leadbay_preview_bulk_enrichment, leadbay_promote_lens, leadbay_pull_followups, leadbay_pull_leads, leadbay_qualify_lead, leadbay_qualify_leads, leadbay_qualify_status, leadbay_recall_ordered_titles, leadbay_refine_prompt, leadbay_remove_contact, leadbay_remove_epilogue, leadbay_remove_leads_from_campaign, leadbay_remove_pushback, leadbay_report_friction, leadbay_report_outreach, leadbay_research_lead_by_id, leadbay_research_lead_by_name_fuzzy, leadbay_resolve_import_rows, leadbay_scan_portfolio_signals, leadbay_seed_candidates, leadbay_select_leads, leadbay_send_feedback, leadbay_set_active_lens, leadbay_set_epilogue_status, leadbay_set_lead_status, leadbay_set_pushback, leadbay_set_qualification_questions, leadbay_set_telemetry, leadbay_set_user_prompt, leadbay_team_activity, leadbay_tour_plan, leadbay_unpin_contact, leadbay_update_contact, leadbay_update_custom_field, leadbay_update_lens, leadbay_update_lens_filter, NO_COMMERCE_TOOL_DESCRIPTIONS;
|
|
1304
1453
|
var init_tool_descriptions_generated = __esm({
|
|
1305
1454
|
"../core/dist/tool-descriptions.generated.js"() {
|
|
1306
1455
|
"use strict";
|
|
@@ -1450,7 +1599,7 @@ account resurfaced:
|
|
|
1450
1599
|
`;
|
|
1451
1600
|
leadbay_account_status = `## WHEN TO USE
|
|
1452
1601
|
|
|
1453
|
-
Trigger phrases: "what's my account status", "how much quota do I have", "what lens am I on", "I topped up / I bought credits / I added credits".
|
|
1602
|
+
Trigger phrases: "what's my account status", "how much quota do I have", "what lens am I on", "I topped up / I bought credits / I added credits", "what version of Leadbay am I running".
|
|
1454
1603
|
|
|
1455
1604
|
Do NOT use for: "show me leads" \u2192 \`leadbay_pull_leads\`.
|
|
1456
1605
|
|
|
@@ -1459,6 +1608,7 @@ Prefer when: meta question about account, quota, active lens, or top-up recovery
|
|
|
1459
1608
|
Examples that SHOULD invoke this tool:
|
|
1460
1609
|
- "What's my account status?"
|
|
1461
1610
|
- "How much quota do I have left this week?"
|
|
1611
|
+
- "Which version of the Leadbay connector is this?"
|
|
1462
1612
|
|
|
1463
1613
|
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
1464
1614
|
- "Show me today's leads."
|
|
@@ -1484,6 +1634,8 @@ Show the user's account state \u2014 admin rights, language, last-active lens, q
|
|
|
1484
1634
|
|
|
1485
1635
|
**After a user tops up, do NOT keep refusing \u2014 RETRY.** If the user signals they topped up / bought credits / added credits, the previous QUOTA_EXCEEDED is invalidated the moment the Stripe webhook lands. RE-CALL \`leadbay_account_status\` to pick up the new state AND retry the originally failed call. The retry itself does not require a successful account_status check first \u2014 a topped-up user has cleared the throttle whether or not your cached snapshot reflects it yet. If the retry hits the wall again, only then re-offer top-up / wait. **A stale quota snapshot is never a reason to gate-keep a topped-up user.**
|
|
1486
1636
|
|
|
1637
|
+
**\`mcp_version\`** is the version of the Leadbay MCP server answering the call. When the user asks which Leadbay version they are running, answer with it.
|
|
1638
|
+
|
|
1487
1639
|
**\`notifications\` block.** The response now includes a top-level \`notifications\` array listing background work the user (or agent) initiated that has since completed (\`bulk_enrich\`, \`bulk_qualify\`, \`import\`). These are signals to revise prior agent outputs the just-finished work might have made stale \u2014 they're NOT a pending-task list for the user. After revising (or confirming nothing is affected), call \`leadbay_acknowledge_notification(notification_id)\`. Full handling protocol below.
|
|
1488
1640
|
|
|
1489
1641
|
## GATE \u2014 INSPECT \`_meta.notifications\` ON EVERY RESPONSE
|
|
@@ -1879,7 +2031,7 @@ Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads be
|
|
|
1879
2031
|
|
|
1880
2032
|
WHEN TO USE: when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty \`qualification_summary\`.
|
|
1881
2033
|
|
|
1882
|
-
WHEN NOT TO USE: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).
|
|
2034
|
+
WHEN NOT TO USE: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced). And NOT for companies the user names or lists themselves (CRM rows, websites, prior deliveries) \u2014 that's leadbay_qualify_leads (server-side batch with per-item verdicts and contact matching); this tool only walks the ACTIVE LENS top-down.
|
|
1883
2035
|
|
|
1884
2036
|
## A launched job cannot be stopped
|
|
1885
2037
|
|
|
@@ -2511,7 +2663,7 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
|
|
|
2511
2663
|
|
|
2512
2664
|
Trigger phrases: "I want more leads on this lens", "extend the lens", "I need a bigger batch today", "fill more leads, I've burned through these", "more leads like the ones in this lens".
|
|
2513
2665
|
|
|
2514
|
-
Do NOT use for: "show me today's leads" \u2192 \`leadbay_pull_leads\`; "narrow the audience" \u2192 \`leadbay_adjust_audience\`; "stop showing me X" \u2192 \`leadbay_refine_prompt\`.
|
|
2666
|
+
Do NOT use for: "show me today's leads" \u2192 \`leadbay_pull_leads\`; "find me companies that <different profile than the lens>" \u2192 \`leadbay_find_new_leads\`; "narrow the audience" \u2192 \`leadbay_adjust_audience\`; "stop showing me X" \u2192 \`leadbay_refine_prompt\`.
|
|
2515
2667
|
|
|
2516
2668
|
Prefer when: user has bigger appetite than the daily lens fill delivers \u2014 additive refill on same criteria
|
|
2517
2669
|
|
|
@@ -2599,6 +2751,256 @@ Pick the row matching the response \`status\`. Seed-picking is internal; do NOT
|
|
|
2599
2751
|
| \`no_candidates\` (\`reason.code: no_new_leads\`) | "Work the leads already in the lens" | \`leadbay_pull_followups()\` |
|
|
2600
2752
|
|
|
2601
2753
|
If nothing matches cleanly, default to "pull leads now to see what's queued" \u2014 never invent a tool that doesn't exist.
|
|
2754
|
+
`;
|
|
2755
|
+
leadbay_find_new_leads = `## WHEN TO USE
|
|
2756
|
+
|
|
2757
|
+
Trigger phrases: "find me N companies that <profile>", "get me new prospects like <company>", "I need leads in <place> that <do X>", "search for companies that would buy <product>", "net-new leads outside my current pipeline", "we're entering <market> \u2014 who should we target".
|
|
2758
|
+
|
|
2759
|
+
Do NOT use for: "show me today's leads / what's new today" \u2192 \`leadbay_pull_leads\`; "find me new leads (no profile, no count named)" \u2192 \`leadbay_pull_leads\`; "more leads like the ones in my lens" \u2192 \`leadbay_extend_lens\`; "qualify / vet these companies I have" \u2192 \`leadbay_qualify_leads\`; "qualify the top N of my batch" \u2192 \`leadbay_bulk_qualify_leads\`; "leads I should follow up with" \u2192 \`leadbay_pull_followups\`; "tell me about <one company>" \u2192 \`leadbay_research_lead_by_name_fuzzy\`.
|
|
2760
|
+
|
|
2761
|
+
Prefer when: the user describes a target profile or names a count of NEW companies \u2014 craft the example_lead per the seed rules below BEFORE calling; never pass the user's raw sentence as query.
|
|
2762
|
+
|
|
2763
|
+
Examples that SHOULD invoke this tool:
|
|
2764
|
+
- "Find me 10 gyms around Dallas that would buy our flooring, with someone I can call."
|
|
2765
|
+
- "Get me 20 new US SaaS companies, 50-2000 employees, with the VP People's email."
|
|
2766
|
+
- "We're launching in Lyon \u2014 find 15 hotels that fit our ICP."
|
|
2767
|
+
|
|
2768
|
+
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
2769
|
+
- "Show me today's leads."
|
|
2770
|
+
- "Which leads should I follow up with this week?"
|
|
2771
|
+
- "Qualify these 40 websites from my spreadsheet."
|
|
2772
|
+
|
|
2773
|
+
## RENDER (quick)
|
|
2774
|
+
|
|
2775
|
+
3-col table of delivered leads in returned order: col 1 = 10-segment fit
|
|
2776
|
+
bar + linked company \xB7 location \xB7 size; col 2 = why-fits \u226420 words; col 3
|
|
2777
|
+
= contact + purchased channels. ALWAYS close with the honest funnel line
|
|
2778
|
+
(matched/examined/delivered/stop reason/spend) \u2014 especially on 0
|
|
2779
|
+
delivered. Full algorithm below.
|
|
2780
|
+
|
|
2781
|
+
---
|
|
2782
|
+
|
|
2783
|
+
Submit a net-new lead search: the backend matches an ICP seed against the full
|
|
2784
|
+
company universe, applies hard filters, skips what the org already knows
|
|
2785
|
+
(\`novelty: org\`), optionally qualifies against the org's own intelligence
|
|
2786
|
+
(questions, tags, ideal buyer profile \u2014 frozen at submit), and optionally buys
|
|
2787
|
+
contact channels. Polls up to \`wait_seconds\` (default 45); a longer job returns
|
|
2788
|
+
\`still_running\` + \`next_poll\` \u2014 hand off to \`leadbay_lead_job_status\`. Jobs run
|
|
2789
|
+
\u226430 min, results kept 30 days.
|
|
2790
|
+
|
|
2791
|
+
**Free vs paid \u2014 never spend silently.** Default (\`qualify: false\`,
|
|
2792
|
+
\`channels: []\`) is FREE: company profile + fit score + cached research +
|
|
2793
|
+
contact identity. Paid: \`qualify: true\` (~94 cost_cents per candidate
|
|
2794
|
+
EXAMINED, capped by \`exploration_cap\`/\`max_cost\`) and \`channels\` (email 25c /
|
|
2795
|
+
phone 250c, success-only). Enforced in code: a paid call is WITHHELD unless it
|
|
2796
|
+
carries \`confirm: true\` \u2014 nothing is submitted and you get
|
|
2797
|
+
\`mode: "needs_confirmation"\` with a real quote to show the user. Re-call with
|
|
2798
|
+
\`confirm: true\` on their go-ahead ("spend / get their emails" counts).
|
|
2799
|
+
\`confirm: false\` vetoes. Free needs no consent. **Preview free first** \u2014
|
|
2800
|
+
reshaping an off-profile seed is free, exploring it with \`qualify: true\` is
|
|
2801
|
+
not.
|
|
2802
|
+
|
|
2803
|
+
**Ad-hoc exclusions ("no chains") are enforced by NO tier** \u2014 \`filters\` has no
|
|
2804
|
+
exclusion key, and \`qualify\` scores against the org's FROZEN questions and IBP,
|
|
2805
|
+
which need not mention chains; the seed's inverse only shifts ranking.
|
|
2806
|
+
Violators can survive, be paid for and be delivered \u2014 post-filter them yourself
|
|
2807
|
+
and say the tier didn't enforce it. Durable enforcement \u2192
|
|
2808
|
+
\`leadbay_refine_prompt\`.
|
|
2809
|
+
|
|
2810
|
+
### Crafting the \`example_lead\` seed \u2014 the input that decides result quality
|
|
2811
|
+
|
|
2812
|
+
The \`example_lead\` is a FICTIONAL typical ideal customer, matched against real
|
|
2813
|
+
registry/website descriptions \u2014 which state what a company **IS**, never what
|
|
2814
|
+
is happening. Write it the same way or the matcher drifts. Every rule below is
|
|
2815
|
+
measured:
|
|
2816
|
+
|
|
2817
|
+
1. **Describe the BUYER, never the seller.** Ask: "would this company write a
|
|
2818
|
+
check to my user?" A seed describing what the user SELLS surfaces their
|
|
2819
|
+
*competitors and vendors*. If the product helps companies of type X serve
|
|
2820
|
+
customers of type Y, the seed describes X \u2014 never Y.
|
|
2821
|
+
2. **Put everything in \`description\`; leave \`name\` unset.** An invented brand
|
|
2822
|
+
name pulls matching toward name-lookalikes \u2014 a seed named "Meridian
|
|
2823
|
+
Analytics" returned five unrelated "Meridian" companies.
|
|
2824
|
+
3. **Registry style, one sentence to ~250 chars.** Industry niche, business
|
|
2825
|
+
model, what they sell or operate, who they serve, observable scale. Write
|
|
2826
|
+
it like the first paragraph of their About-Us page.
|
|
2827
|
+
- STRONG: "Operator of full-service fitness centers offering strength
|
|
2828
|
+
areas, group classes and personal training to members across multiple
|
|
2829
|
+
clubs."
|
|
2830
|
+
- WEAK (generic): "A gym in Texas."
|
|
2831
|
+
- WRONG (seller-side): "Supplier of durable modular flooring for gyms."
|
|
2832
|
+
4. **No event language.** "hiring", "expanding", "just raised" are not
|
|
2833
|
+
filters \u2014 registry descriptions never contain them, so they dilute the
|
|
2834
|
+
profile. Purchase triggers belong in the org's qualification questions.
|
|
2835
|
+
5. **No meta-markers.** Never "(example)", "(fictional)", "(placeholder)".
|
|
2836
|
+
6. **Hard constraints go in \`filters\`, not prose \u2014 exact keys:**
|
|
2837
|
+
\`sectors: string[]\`, \`locations: string[]\`, \`employees_min: number\`,
|
|
2838
|
+
\`employees_max: number\`. FLAT numbers \u2014 nested \`employees: {min, max}\`
|
|
2839
|
+
exists only in RESULT payloads. \`example_lead.employees\` does not filter.
|
|
2840
|
+
\`locations\` take city/state/region names ("Dallas, TX", "\xCEle-de-France");
|
|
2841
|
+
a country name is refused in code \u2014 whole-country intent = omit it.
|
|
2842
|
+
7. **Prefer \`example_lead\` over \`query\`.** Query matches topic *vocabulary*:
|
|
2843
|
+
"gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered.
|
|
2844
|
+
Use \`query\` only for signal an example can't express.
|
|
2845
|
+
8. **One seed per buyer archetype.** An ask spanning two segments ("gyms and
|
|
2846
|
+
warehouses") needs one search each with its own description and
|
|
2847
|
+
\`request_id\` \u2014 a blended seed lands between the clusters and matches
|
|
2848
|
+
neither.
|
|
2849
|
+
|
|
2850
|
+
|
|
2851
|
+
**Parameter notes**
|
|
2852
|
+
- \`request_id\` (REQUIRED) is the retry contract: SAME value retrying the same
|
|
2853
|
+
ask (same live job, no double spend); NEW for a changed ask. Derive from ask
|
|
2854
|
+
+ archetype + date: \`gyms-dallas-2026-07-28\`.
|
|
2855
|
+
- Never lower \`min_ai_score\` together with \`channels\` \u2014 that buys emails for
|
|
2856
|
+
leads the AI just scored as junk.
|
|
2857
|
+
- \`count\` \u2264 50; \u22643 active jobs/org; \u226410 submits/hour (429 + Retry-After \u2014
|
|
2858
|
+
wait, don't hammer).
|
|
2859
|
+
|
|
2860
|
+
**Read the result honestly** \u2014 \`funnel\` + \`explain.scope_notes\` tell the story;
|
|
2861
|
+
zero delivered gets a cause and a next move (rules in RENDERING).
|
|
2862
|
+
|
|
2863
|
+
---
|
|
2864
|
+
|
|
2865
|
+
## RENDERING \u2014 delivery table + honest funnel line
|
|
2866
|
+
|
|
2867
|
+
Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or
|
|
2868
|
+
\`degraded\`) as a markdown table **in the order returned**. Exactly three
|
|
2869
|
+
columns. Then ALWAYS close with the funnel line (below) \u2014 even, especially,
|
|
2870
|
+
when nothing was delivered.
|
|
2871
|
+
|
|
2872
|
+
**Column 1 \u2014 Company**
|
|
2873
|
+
|
|
2874
|
+
- Line 1: 10-segment fit bar in inline-code backticks from \`lead.fit.score\`
|
|
2875
|
+
(0-100): \`filled = round(score/10)\`, glyphs \`\u25B0\` filled / \`\u25B1\` empty. When
|
|
2876
|
+
\`lead.fit.components.qualification.available\` is true AND \`ai_score > 0\`,
|
|
2877
|
+
replace the LAST filled segment with \`\u2756\` (AI-confirmed cap). When
|
|
2878
|
+
\`fit.available\` is false, render \`\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\` and say "unscored" in col 2.
|
|
2879
|
+
Never print the numeric score.
|
|
2880
|
+
- Insert \`<br>\`, then: linked company name (target \`company.website\`, bare
|
|
2881
|
+
hostnames get \`https://\`; unlinked plain text when absent) + \` \xB7 \` + short
|
|
2882
|
+
location (City, ST / City, Country) + \` \xB7 \` + employees as \`min\u2013max\` (omit
|
|
2883
|
+
when \`employees.known\` is false).
|
|
2884
|
+
|
|
2885
|
+
**Column 2 \u2014 Why it fits**
|
|
2886
|
+
|
|
2887
|
+
- One sentence \u2264 20 words. Priority: \`fit.reasoning\` \u2192 gist of
|
|
2888
|
+
\`company.description\` \u2192 top \`fit.components.qualification.matched_tags\`.
|
|
2889
|
+
- If the item status is \`degraded\` or a requested channel failed, append the
|
|
2890
|
+
honest flag in italics, e.g. *(email could not be sourced)*.
|
|
2891
|
+
|
|
2892
|
+
**Column 3 \u2014 Contact**
|
|
2893
|
+
|
|
2894
|
+
- \`[Name](linkedin) \xB7 role\` (linked name mandatory when a LinkedIn URL
|
|
2895
|
+
exists; plain name otherwise). Below it, the PURCHASED channels only:
|
|
2896
|
+
\`\u2709 value\` / \`\u260E value\` inline as plain text (they auto-linkify).
|
|
2897
|
+
- Channel statuses: \`delivered\` \u2192 show value; \`already_owned\` \u2192 value +
|
|
2898
|
+
*(already yours)*; \`masked\` \u2192 "on file \u2014 reveal via channels";
|
|
2899
|
+
\`not_requested\` \u2192 omit; \`failed_*\` \u2192 *(no verified email/phone)*.
|
|
2900
|
+
- No contact on the item (\`contact\` null): render \`\u2014\` (title_gate \`prefer\`
|
|
2901
|
+
delivers such rows flagged; say so in col 2 only when contact_titles were
|
|
2902
|
+
requested).
|
|
2903
|
+
|
|
2904
|
+
**The funnel line (mandatory, after the table):**
|
|
2905
|
+
|
|
2906
|
+
One short line narrating the delivery honestly, from \`funnel\` + \`cost\` +
|
|
2907
|
+
\`explain.scope_notes\`:
|
|
2908
|
+
|
|
2909
|
+
> Matched N \xB7 examined E \xB7 qualified Q \xB7 disqualified D \u2192 **delivered X of
|
|
2910
|
+
> the Y asked** \xB7 stopped: <stop_reason in plain words> \xB7 spent C.CC.
|
|
2911
|
+
|
|
2912
|
+
**Money: divide, then symbol.** Every amount (\`cost.spent\`,
|
|
2913
|
+
\`estimated_cost.max\`, quotes) is \`cost_cents\` \u2014 divide by 100, two decimals,
|
|
2914
|
+
so \`165\` renders \`1.65\`, NEVER \`165.00\`. Symbol from the account region: US
|
|
2915
|
+
\`$\`, France \`\u20AC\`, unknown \u2192 bare. Never hard-code \`$\`: it misstates a charge.
|
|
2916
|
+
|
|
2917
|
+
"of the Y asked" needs \`summary.items_requested\`, which submits carry but a
|
|
2918
|
+
later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered
|
|
2919
|
+
X** and stop \u2014 never back-fill Y from \`matched\`/\`examined\` (they count
|
|
2920
|
+
candidates), never guess it.
|
|
2921
|
+
|
|
2922
|
+
Plain-word stop reasons: \`target_reached\` \u2192 omit (success), \`pool_exhausted\` \u2192
|
|
2923
|
+
"ran out of matching candidates", \`max_cost\` \u2192 "hit the cost cap", \`quota\` \u2192
|
|
2924
|
+
"hit an org quota", \`time_budget\` \u2192 "hit the 30-min time budget".
|
|
2925
|
+
|
|
2926
|
+
**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give
|
|
2927
|
+
the funnel line plus the relevant \`explain.scope_notes\` (the backend's own
|
|
2928
|
+
diagnosis), then propose the concrete fix (reshape the seed per the craft
|
|
2929
|
+
rules, lower \`min_ai_score\`, raise \`max_cost\`, drop a filter) as NEXT STEPS.
|
|
2930
|
+
|
|
2931
|
+
**Weak batch**: when the BEST delivered \`fit.score\` is under 30, don't present
|
|
2932
|
+
the table as an answer \u2014 open with "weak matches only", show at most the top 3,
|
|
2933
|
+
propose reshaping the seed/filters first. The count was filled with
|
|
2934
|
+
barely-better-than-random candidates.
|
|
2935
|
+
|
|
2936
|
+
**Sanity-check every row**: (a) geo \u2014 \`city\`/\`region\` must sit inside any
|
|
2937
|
+
requested fence; drop and call out leaks (same-named cities slip through).
|
|
2938
|
+
(b) When \`explain.seed_strategy\` is \`text_match_exemplars\` (the standard FR
|
|
2939
|
+
path), fit is calibrated for lead-to-lead distances, not exemplar centroids \u2014
|
|
2940
|
+
treat high scores skeptically and verify each row's \`description\`.
|
|
2941
|
+
|
|
2942
|
+
**Skipped items** (\`skipped[]\`, qualify jobs mostly): render a compact second
|
|
2943
|
+
table \`Ref \u2192 Outcome\` translating \`status_reason\` to plain words:
|
|
2944
|
+
\`not_in_universe\` \u2192 "not in the Leadbay universe (import it first)",
|
|
2945
|
+
\`low_confidence_identity\` \u2192 "couldn't safely match \u2014 check \`resolution.alternatives\`",
|
|
2946
|
+
\`no_matching_contact\` \u2192 "no contact with the requested title",
|
|
2947
|
+
\`disqualified\` \u2192 "evaluated: does not fit" (evidence is in the item when owned),
|
|
2948
|
+
\`enrichment_failed\` \u2192 "channel could not be sourced (not billed)".
|
|
2949
|
+
|
|
2950
|
+
**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer
|
|
2951
|
+
\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest.
|
|
2952
|
+
|
|
2953
|
+
**Hide from the user:** UUIDs (keep for tool calls, never render), cursors,
|
|
2954
|
+
\`explain.model\`/\`intelligence_snapshot\`, raw \`distance\`/\`calibration\`,
|
|
2955
|
+
\`seq\`/\`from_cache\`, empty arrays.
|
|
2956
|
+
|
|
2957
|
+
## Linking a contact's name
|
|
2958
|
+
|
|
2959
|
+
**MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
|
|
2960
|
+
|
|
2961
|
+
URL priority (first applicable wins):
|
|
2962
|
+
|
|
2963
|
+
1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
|
|
2964
|
+
2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
|
|
2965
|
+
|
|
2966
|
+
Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
|
|
2967
|
+
|
|
2968
|
+
|
|
2969
|
+
|
|
2970
|
+
---
|
|
2971
|
+
|
|
2972
|
+
## NEXT STEPS \u2014 after a find_new_leads delivery
|
|
2973
|
+
|
|
2974
|
+
**ALWAYS render NEXT STEPS via your host's next-step widget.** Use whichever is in your tool set \u2014 the NAME and SCHEMA differ: **\`ask_user_input_v0\`** (Claude chat / ChatGPT) takes plain-string options with \`type:"single_select"\`; **\`AskUserQuestion\`** (Claude cowork / Claude Code) takes object options \`{label, description}\` plus a required short \`header\` (\u226412 chars) and \`multiSelect\`, NO \`type\` field, and never add an "Other" option (the host adds it). Match the schema to the tool you actually have \u2014 the wrong schema fails silently and you fall back to prose. Prose bullets are the fallback ONLY when NEITHER widget exists. Any turn that would end with a choice must be the widget \u2014 the widget IS the question.
|
|
2975
|
+
|
|
2976
|
+
**If the tool result carries a \`next_steps\` object, that is the source of truth \u2014 use it directly.** Each option has a short \`.label\` (\u22645 words) and a full \`.description\`. Map \`next_steps.options[]\` into your host widget VERBATIM and in order: for \`AskUserQuestion\` (cowork / Claude Code) pass each as \`{label, description}\`; for \`ask_user_input_v0\` (Claude chat / ChatGPT, string options only) pass each option's \`.description\` as the string (it's the full sentence). Do NOT reword, reorder, drop, or prose-ify them \u2014 they're built deterministically by the server so the offer (incl. the artifact option at position 0) fires every time. Fall back to the table below only when there is NO \`next_steps\` field.
|
|
2977
|
+
|
|
2978
|
+
**One exception \u2014 skip the widget** when the user's original message contained a complete sequential instruction chain ("show me X and then do Y") AND all stated steps have been completed. In that case, end with STOP directly \u2014 the user stated their full plan and does not need a "what next?" prompt.
|
|
2979
|
+
- Skip example: "Show me today's leads and then research the top one for me." \u2192 after research completes, emit STOP without the widget.
|
|
2980
|
+
- Do NOT skip for: plain requests ("show me today's leads", "run my check-in"), recurring-language requests ("I do this every day"), or requests where only one action was stated.
|
|
2981
|
+
|
|
2982
|
+
Pick 2\u20134 rows from the (Observation, Suggest, Calls) table below most relevant to the response, then call your host's widget with ITS schema (per the schema rules above \u2014 wrong schema fails silently):
|
|
2983
|
+
- \`ask_user_input_v0\`: \`{questions:[{question,type:"single_select",options:["<Suggest 1>","<Suggest 2>"]}]}\`
|
|
2984
|
+
- \`AskUserQuestion\`: \`{questions:[{question,header:"Next step",multiSelect:false,options:[{label:"<\u22645 words>",description:"<Suggest 1>"}]}]}\`
|
|
2985
|
+
|
|
2986
|
+
User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutually-exclusive options, AskUserQuestion labels \u22645 words (full text in \`description\`), max 3 questions. Table stays internal; never recite it.
|
|
2987
|
+
|
|
2988
|
+
---
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
|
|
2992
|
+
Pick the 2-3 options that match what actually happened \u2014 never all seven:
|
|
2993
|
+
|
|
2994
|
+
| Observation | Suggest | Calls |
|
|
2995
|
+
|---|---|---|
|
|
2996
|
+
| Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
|
|
2997
|
+
| Free run delivered on-profile leads | "Qualify these N against your criteria (paid \u2014 \`dry_run\` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) |
|
|
2998
|
+
| Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
|
|
2999
|
+
| Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
|
|
3000
|
+
| Stopped at cost cap (\`stop_reason: max_cost\`) | "Raise the cap to X and get the remaining N" \u2014 X in the account's currency per the funnel-line rule, never a hard-coded \`$\` | leadbay_find_new_leads, NEW request_id (same-id only dedupes onto a LIVE job) + higher max_cost + \`count\` = the SHORTFALL (\`items_requested\` \u2212 delivered), not the original + \`exclude_lead_ids\` = the examined-but-REJECTED ids (novelty covers delivered; these are what it misses \u2014 without them the rerun re-buys the same losers) |
|
|
3001
|
+
| Stopped on org quota (\`stop_reason: quota\`) | "Check which window is exhausted and when it resets" \u2014 never a re-run: it cannot clear an org quota and burns a submit slot to stop in the same place | leadbay_account_status |
|
|
3002
|
+
| Stopped on org quota and the user does not want to wait | "Top up to finish this run" | leadbay_create_topup_link |
|
|
3003
|
+
| User wants these tracked in Leadbay | "Add the keepers to a campaign" | leadbay_create_campaign / leadbay_add_leads_to_campaign |
|
|
2602
3004
|
`;
|
|
2603
3005
|
leadbay_followups_map = `## WHEN TO USE
|
|
2604
3006
|
|
|
@@ -3463,6 +3865,199 @@ spend before spending it again.
|
|
|
3463
3865
|
|
|
3464
3866
|
|
|
3465
3867
|
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\`.
|
|
3868
|
+
`;
|
|
3869
|
+
leadbay_lead_job_status = `## WHEN TO USE
|
|
3870
|
+
|
|
3871
|
+
Trigger phrases: "is the lead search done", "any results yet on that job", "check on the delivery".
|
|
3872
|
+
|
|
3873
|
+
Do NOT use for: "is the enrichment done" \u2192 \`leadbay_bulk_enrich_status\`; "is my import done" \u2192 \`leadbay_import_status\`; "is the top-N qualification done" \u2192 \`leadbay_qualify_status\`.
|
|
3874
|
+
|
|
3875
|
+
Prefer when: a find_new_leads / qualify_leads result carried next_poll \u2014 pass its job_id; use wait_seconds ~60 when the user asked to wait for results.
|
|
3876
|
+
|
|
3877
|
+
Examples that SHOULD invoke this tool:
|
|
3878
|
+
- "Any leads yet from that search you started?"
|
|
3879
|
+
- "Wait for the qualification job to finish and show me everything."
|
|
3880
|
+
|
|
3881
|
+
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
3882
|
+
- "Is the email enrichment finished?"
|
|
3883
|
+
- "Is my CSV import done?"
|
|
3884
|
+
|
|
3885
|
+
## RENDER (quick)
|
|
3886
|
+
|
|
3887
|
+
Terminal job -> render the full delivery per the lead-delivery table +
|
|
3888
|
+
honest funnel line. Still running -> one progress line (examined /
|
|
3889
|
+
delivered / spent so far) and offer to check again in ~1 min. Never
|
|
3890
|
+
render UUIDs or cursors.
|
|
3891
|
+
|
|
3892
|
+
---
|
|
3893
|
+
|
|
3894
|
+
Cumulative snapshot of a lead-delivery job: state, funnel counters, every
|
|
3895
|
+
item emitted so far (full lead payloads for delivered/degraded, honest
|
|
3896
|
+
status_reason for skipped), spend + breakdown, and the \`explain\` block
|
|
3897
|
+
(basis, seed strategy, scope notes). Items are immutable once emitted \u2014
|
|
3898
|
+
polling never re-reads live data, so numbers only ever grow.
|
|
3899
|
+
|
|
3900
|
+
\`wait_seconds: 0\` (default) answers instantly; set ~60 to block-wait for
|
|
3901
|
+
completion when the user asked for results "in this reply". \`since\` (from a
|
|
3902
|
+
prior poll's \`next_since\`) pages only the new items. Jobs terminalize
|
|
3903
|
+
server-side: past the 30-min wall clock a job reads \`completed_partial\`
|
|
3904
|
+
(time budget), past 30 days \`expired\` (items no longer listed \u2014 re-read
|
|
3905
|
+
billed leads via leadbay_qualify_leads \`prior_deliveries\`). A 404 means
|
|
3906
|
+
unknown job or another org's job.
|
|
3907
|
+
|
|
3908
|
+
---
|
|
3909
|
+
|
|
3910
|
+
## RENDERING \u2014 delivery table + honest funnel line
|
|
3911
|
+
|
|
3912
|
+
Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or
|
|
3913
|
+
\`degraded\`) as a markdown table **in the order returned**. Exactly three
|
|
3914
|
+
columns. Then ALWAYS close with the funnel line (below) \u2014 even, especially,
|
|
3915
|
+
when nothing was delivered.
|
|
3916
|
+
|
|
3917
|
+
**Column 1 \u2014 Company**
|
|
3918
|
+
|
|
3919
|
+
- Line 1: 10-segment fit bar in inline-code backticks from \`lead.fit.score\`
|
|
3920
|
+
(0-100): \`filled = round(score/10)\`, glyphs \`\u25B0\` filled / \`\u25B1\` empty. When
|
|
3921
|
+
\`lead.fit.components.qualification.available\` is true AND \`ai_score > 0\`,
|
|
3922
|
+
replace the LAST filled segment with \`\u2756\` (AI-confirmed cap). When
|
|
3923
|
+
\`fit.available\` is false, render \`\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\` and say "unscored" in col 2.
|
|
3924
|
+
Never print the numeric score.
|
|
3925
|
+
- Insert \`<br>\`, then: linked company name (target \`company.website\`, bare
|
|
3926
|
+
hostnames get \`https://\`; unlinked plain text when absent) + \` \xB7 \` + short
|
|
3927
|
+
location (City, ST / City, Country) + \` \xB7 \` + employees as \`min\u2013max\` (omit
|
|
3928
|
+
when \`employees.known\` is false).
|
|
3929
|
+
|
|
3930
|
+
**Column 2 \u2014 Why it fits**
|
|
3931
|
+
|
|
3932
|
+
- One sentence \u2264 20 words. Priority: \`fit.reasoning\` \u2192 gist of
|
|
3933
|
+
\`company.description\` \u2192 top \`fit.components.qualification.matched_tags\`.
|
|
3934
|
+
- If the item status is \`degraded\` or a requested channel failed, append the
|
|
3935
|
+
honest flag in italics, e.g. *(email could not be sourced)*.
|
|
3936
|
+
|
|
3937
|
+
**Column 3 \u2014 Contact**
|
|
3938
|
+
|
|
3939
|
+
- \`[Name](linkedin) \xB7 role\` (linked name mandatory when a LinkedIn URL
|
|
3940
|
+
exists; plain name otherwise). Below it, the PURCHASED channels only:
|
|
3941
|
+
\`\u2709 value\` / \`\u260E value\` inline as plain text (they auto-linkify).
|
|
3942
|
+
- Channel statuses: \`delivered\` \u2192 show value; \`already_owned\` \u2192 value +
|
|
3943
|
+
*(already yours)*; \`masked\` \u2192 "on file \u2014 reveal via channels";
|
|
3944
|
+
\`not_requested\` \u2192 omit; \`failed_*\` \u2192 *(no verified email/phone)*.
|
|
3945
|
+
- No contact on the item (\`contact\` null): render \`\u2014\` (title_gate \`prefer\`
|
|
3946
|
+
delivers such rows flagged; say so in col 2 only when contact_titles were
|
|
3947
|
+
requested).
|
|
3948
|
+
|
|
3949
|
+
**The funnel line (mandatory, after the table):**
|
|
3950
|
+
|
|
3951
|
+
One short line narrating the delivery honestly, from \`funnel\` + \`cost\` +
|
|
3952
|
+
\`explain.scope_notes\`:
|
|
3953
|
+
|
|
3954
|
+
> Matched N \xB7 examined E \xB7 qualified Q \xB7 disqualified D \u2192 **delivered X of
|
|
3955
|
+
> the Y asked** \xB7 stopped: <stop_reason in plain words> \xB7 spent C.CC.
|
|
3956
|
+
|
|
3957
|
+
**Money: divide, then symbol.** Every amount (\`cost.spent\`,
|
|
3958
|
+
\`estimated_cost.max\`, quotes) is \`cost_cents\` \u2014 divide by 100, two decimals,
|
|
3959
|
+
so \`165\` renders \`1.65\`, NEVER \`165.00\`. Symbol from the account region: US
|
|
3960
|
+
\`$\`, France \`\u20AC\`, unknown \u2192 bare. Never hard-code \`$\`: it misstates a charge.
|
|
3961
|
+
|
|
3962
|
+
"of the Y asked" needs \`summary.items_requested\`, which submits carry but a
|
|
3963
|
+
later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered
|
|
3964
|
+
X** and stop \u2014 never back-fill Y from \`matched\`/\`examined\` (they count
|
|
3965
|
+
candidates), never guess it.
|
|
3966
|
+
|
|
3967
|
+
Plain-word stop reasons: \`target_reached\` \u2192 omit (success), \`pool_exhausted\` \u2192
|
|
3968
|
+
"ran out of matching candidates", \`max_cost\` \u2192 "hit the cost cap", \`quota\` \u2192
|
|
3969
|
+
"hit an org quota", \`time_budget\` \u2192 "hit the 30-min time budget".
|
|
3970
|
+
|
|
3971
|
+
**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give
|
|
3972
|
+
the funnel line plus the relevant \`explain.scope_notes\` (the backend's own
|
|
3973
|
+
diagnosis), then propose the concrete fix (reshape the seed per the craft
|
|
3974
|
+
rules, lower \`min_ai_score\`, raise \`max_cost\`, drop a filter) as NEXT STEPS.
|
|
3975
|
+
|
|
3976
|
+
**Weak batch**: when the BEST delivered \`fit.score\` is under 30, don't present
|
|
3977
|
+
the table as an answer \u2014 open with "weak matches only", show at most the top 3,
|
|
3978
|
+
propose reshaping the seed/filters first. The count was filled with
|
|
3979
|
+
barely-better-than-random candidates.
|
|
3980
|
+
|
|
3981
|
+
**Sanity-check every row**: (a) geo \u2014 \`city\`/\`region\` must sit inside any
|
|
3982
|
+
requested fence; drop and call out leaks (same-named cities slip through).
|
|
3983
|
+
(b) When \`explain.seed_strategy\` is \`text_match_exemplars\` (the standard FR
|
|
3984
|
+
path), fit is calibrated for lead-to-lead distances, not exemplar centroids \u2014
|
|
3985
|
+
treat high scores skeptically and verify each row's \`description\`.
|
|
3986
|
+
|
|
3987
|
+
**Skipped items** (\`skipped[]\`, qualify jobs mostly): render a compact second
|
|
3988
|
+
table \`Ref \u2192 Outcome\` translating \`status_reason\` to plain words:
|
|
3989
|
+
\`not_in_universe\` \u2192 "not in the Leadbay universe (import it first)",
|
|
3990
|
+
\`low_confidence_identity\` \u2192 "couldn't safely match \u2014 check \`resolution.alternatives\`",
|
|
3991
|
+
\`no_matching_contact\` \u2192 "no contact with the requested title",
|
|
3992
|
+
\`disqualified\` \u2192 "evaluated: does not fit" (evidence is in the item when owned),
|
|
3993
|
+
\`enrichment_failed\` \u2192 "channel could not be sourced (not billed)".
|
|
3994
|
+
|
|
3995
|
+
**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer
|
|
3996
|
+
\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest.
|
|
3997
|
+
|
|
3998
|
+
**Hide from the user:** UUIDs (keep for tool calls, never render), cursors,
|
|
3999
|
+
\`explain.model\`/\`intelligence_snapshot\`, raw \`distance\`/\`calibration\`,
|
|
4000
|
+
\`seq\`/\`from_cache\`, empty arrays.
|
|
4001
|
+
|
|
4002
|
+
## Linking a contact's name
|
|
4003
|
+
|
|
4004
|
+
**MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
|
|
4005
|
+
|
|
4006
|
+
URL priority (first applicable wins):
|
|
4007
|
+
|
|
4008
|
+
1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
|
|
4009
|
+
2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
|
|
4010
|
+
|
|
4011
|
+
Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
|
|
4012
|
+
|
|
4013
|
+
|
|
4014
|
+
|
|
4015
|
+
**Delivered \u2260 endorsed.** This tool DELIVERS org-owned companies that FAILED
|
|
4016
|
+
qualification, carrying their negative evidence \u2014 so a delivered item is not
|
|
4017
|
+
automatically a prospect. An item whose \`status_reason\` is \`disqualified\`, or
|
|
4018
|
+
whose \`fit.components.qualification\` is available with a negative \`ai_score\`,
|
|
4019
|
+
must NOT go in the fit table: its firmographic score can still be high, and a
|
|
4020
|
+
full bar beside "why it fits" reads as a recommendation to call an account the
|
|
4021
|
+
evaluation just rejected.
|
|
4022
|
+
|
|
4023
|
+
Give those their own short section after the fit table, titled
|
|
4024
|
+
**Evaluated \u2014 does not fit**: linked company, then the verdict in plain
|
|
4025
|
+
words from the
|
|
4026
|
+
qualification evidence (failed question verdicts, missed tags, IBP reasoning).
|
|
4027
|
+
That is the deliverable \u2014 "here's why to skip this account" \u2014 not a defect to
|
|
4028
|
+
hide.
|
|
4029
|
+
|
|
4030
|
+
|
|
4031
|
+
---
|
|
4032
|
+
|
|
4033
|
+
## NEXT STEPS \u2014 after a job status poll
|
|
4034
|
+
|
|
4035
|
+
**ALWAYS render NEXT STEPS via your host's next-step widget.** Use whichever is in your tool set \u2014 the NAME and SCHEMA differ: **\`ask_user_input_v0\`** (Claude chat / ChatGPT) takes plain-string options with \`type:"single_select"\`; **\`AskUserQuestion\`** (Claude cowork / Claude Code) takes object options \`{label, description}\` plus a required short \`header\` (\u226412 chars) and \`multiSelect\`, NO \`type\` field, and never add an "Other" option (the host adds it). Match the schema to the tool you actually have \u2014 the wrong schema fails silently and you fall back to prose. Prose bullets are the fallback ONLY when NEITHER widget exists. Any turn that would end with a choice must be the widget \u2014 the widget IS the question.
|
|
4036
|
+
|
|
4037
|
+
**If the tool result carries a \`next_steps\` object, that is the source of truth \u2014 use it directly.** Each option has a short \`.label\` (\u22645 words) and a full \`.description\`. Map \`next_steps.options[]\` into your host widget VERBATIM and in order: for \`AskUserQuestion\` (cowork / Claude Code) pass each as \`{label, description}\`; for \`ask_user_input_v0\` (Claude chat / ChatGPT, string options only) pass each option's \`.description\` as the string (it's the full sentence). Do NOT reword, reorder, drop, or prose-ify them \u2014 they're built deterministically by the server so the offer (incl. the artifact option at position 0) fires every time. Fall back to the table below only when there is NO \`next_steps\` field.
|
|
4038
|
+
|
|
4039
|
+
**One exception \u2014 skip the widget** when the user's original message contained a complete sequential instruction chain ("show me X and then do Y") AND all stated steps have been completed. In that case, end with STOP directly \u2014 the user stated their full plan and does not need a "what next?" prompt.
|
|
4040
|
+
- Skip example: "Show me today's leads and then research the top one for me." \u2192 after research completes, emit STOP without the widget.
|
|
4041
|
+
- Do NOT skip for: plain requests ("show me today's leads", "run my check-in"), recurring-language requests ("I do this every day"), or requests where only one action was stated.
|
|
4042
|
+
|
|
4043
|
+
Pick 2\u20134 rows from the (Observation, Suggest, Calls) table below most relevant to the response, then call your host's widget with ITS schema (per the schema rules above \u2014 wrong schema fails silently):
|
|
4044
|
+
- \`ask_user_input_v0\`: \`{questions:[{question,type:"single_select",options:["<Suggest 1>","<Suggest 2>"]}]}\`
|
|
4045
|
+
- \`AskUserQuestion\`: \`{questions:[{question,header:"Next step",multiSelect:false,options:[{label:"<\u22645 words>",description:"<Suggest 1>"}]}]}\`
|
|
4046
|
+
|
|
4047
|
+
User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutually-exclusive options, AskUserQuestion labels \u22645 words (full text in \`description\`), max 3 questions. Table stays internal; never recite it.
|
|
4048
|
+
|
|
4049
|
+
---
|
|
4050
|
+
|
|
4051
|
+
|
|
4052
|
+
|
|
4053
|
+
Pick the ONE row matching the job's state and offer at most two options \u2014 this
|
|
4054
|
+
is a status tool, keep it terse:
|
|
4055
|
+
|
|
4056
|
+
| Observation | Suggest | Calls |
|
|
4057
|
+
|---|---|---|
|
|
4058
|
+
| Still running | "Keep waiting (~1 min) or leave it \u2014 results are kept 30 days" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
|
|
4059
|
+
| Terminal (completed / partial / failed) | Render the delivery per the RENDERING block, then offer the matching find_new_leads / qualify_leads NEXT STEPS | \u2014 |
|
|
4060
|
+
| \`expired\` (past the 30-day window) | "Re-read the billed leads from your delivery ledger" \u2014 there is nothing left to render: the job terminalized and its items are no longer listed, so do NOT present an empty delivery as a result | leadbay_qualify_leads(prior_deliveries: {job_id}) |
|
|
3466
4061
|
`;
|
|
3467
4062
|
leadbay_like_lead = `## WHEN TO USE
|
|
3468
4063
|
|
|
@@ -4362,7 +4957,7 @@ Always offer at least one of: prep outreach, refilter, pushback. Pushback is the
|
|
|
4362
4957
|
|
|
4363
4958
|
Trigger phrases: "show me leads", "show me new leads", "show me today's leads", "today's prospects", "best new leads", "fresh leads", "what's new today".
|
|
4364
4959
|
|
|
4365
|
-
Do NOT use for: "leads I should follow up with" \u2192 \`leadbay_pull_followups\`; "I'm going to <city>" \u2192 \`leadbay_tour_plan\`; "I'm in <city> next week \u2014 who's worth meeting" \u2192 \`leadbay_tour_plan\`; "who should I meet in <city>" \u2192 \`leadbay_tour_plan\`; "visiting <city> \u2014 who's worth meeting / seeing" \u2192 \`leadbay_tour_plan\`; "leads I should reach out to" \u2192 \`leadbay_pull_followups\`; "leads to get back to" \u2192 \`leadbay_pull_followups\`; "leads to contact today" \u2192 \`leadbay_pull_followups\`; "should I contact" \u2192 \`leadbay_pull_followups\`; "reconnect with" \u2192 \`leadbay_pull_followups\`; "re-engage" \u2192 \`leadbay_pull_followups\`.
|
|
4960
|
+
Do NOT use for: "find me N companies that <specific profile>" \u2192 \`leadbay_find_new_leads\`; "new prospects like <company> with their emails" \u2192 \`leadbay_find_new_leads\`; "leads I should follow up with" \u2192 \`leadbay_pull_followups\`; "I'm going to <city>" \u2192 \`leadbay_tour_plan\`; "I'm in <city> next week \u2014 who's worth meeting" \u2192 \`leadbay_tour_plan\`; "who should I meet in <city>" \u2192 \`leadbay_tour_plan\`; "visiting <city> \u2014 who's worth meeting / seeing" \u2192 \`leadbay_tour_plan\`; "leads I should reach out to" \u2192 \`leadbay_pull_followups\`; "leads to get back to" \u2192 \`leadbay_pull_followups\`; "leads to contact today" \u2192 \`leadbay_pull_followups\`; "should I contact" \u2192 \`leadbay_pull_followups\`; "reconnect with" \u2192 \`leadbay_pull_followups\`; "re-engage" \u2192 \`leadbay_pull_followups\`.
|
|
4366
4961
|
|
|
4367
4962
|
Prefer when: fresh Discover leads; if a lens is named, pass \`lensId\` and pin it
|
|
4368
4963
|
|
|
@@ -4371,6 +4966,7 @@ Examples that SHOULD invoke this tool:
|
|
|
4371
4966
|
- "Pull my best new prospects."
|
|
4372
4967
|
|
|
4373
4968
|
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
4969
|
+
- "Find me 10 gyms around Dallas that would buy our flooring."
|
|
4374
4970
|
- "Which leads should I follow up with this week?"
|
|
4375
4971
|
- "I'm flying to Berlin Thursday \u2014 who should I meet?"
|
|
4376
4972
|
- "I'm in San Francisco next Tuesday \u2014 who's worth meeting?"
|
|
@@ -4400,7 +4996,7 @@ Every lead carries \`recommended_contact\` (with \`linkedin_page\` when the back
|
|
|
4400
4996
|
|
|
4401
4997
|
WHEN TO USE: as the agent's default opening move when the user wants to see leads, or as a daily check-in for what's new today.
|
|
4402
4998
|
|
|
4403
|
-
WHEN NOT TO USE: when the user has named a specific lens \u2014 pass \`lensId\` to override the auto-resolution.
|
|
4999
|
+
WHEN NOT TO USE: when the user has named a specific lens \u2014 pass \`lensId\` to override the auto-resolution.
|
|
4404
5000
|
|
|
4405
5001
|
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.
|
|
4406
5002
|
|
|
@@ -4549,6 +5145,231 @@ spend before spending it again.
|
|
|
4549
5145
|
|
|
4550
5146
|
|
|
4551
5147
|
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\`.
|
|
5148
|
+
`;
|
|
5149
|
+
leadbay_qualify_leads = `## WHEN TO USE
|
|
5150
|
+
|
|
5151
|
+
Trigger phrases: "qualify these companies", "vet this list", "which of these fit our ICP", "score these websites / accounts", "get me the right contact at these companies", "re-qualify what you delivered last week".
|
|
5152
|
+
|
|
5153
|
+
Do NOT use for: "find me new leads / companies that <profile>" \u2192 \`leadbay_find_new_leads\`; "qualify the top N of my lens batch" \u2192 \`leadbay_bulk_qualify_leads\`; "import this CSV file" \u2192 \`leadbay_import_leads\`; "tell me about <one company> in depth" \u2192 \`leadbay_research_lead_by_name_fuzzy\`; "add emails to the contacts I selected" \u2192 \`leadbay_enrich_titles\`.
|
|
5154
|
+
|
|
5155
|
+
Prefer when: the user points at SPECIFIC companies (ids, websites, names, a pasted list, "what you found yesterday") and wants fit verdicts and/or the right person to talk to.
|
|
5156
|
+
|
|
5157
|
+
Examples that SHOULD invoke this tool:
|
|
5158
|
+
- "Here are 60 restaurant websites from my Austin sweep \u2014 which fit, and who's the owner?"
|
|
5159
|
+
- "Re-qualify last week's delivery and get phone numbers for the good ones."
|
|
5160
|
+
- "Vet these 12 accounts from my spreadsheet against our criteria."
|
|
5161
|
+
|
|
5162
|
+
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
5163
|
+
- "Find me 10 new gyms in Texas."
|
|
5164
|
+
- "Qualify the top 10 leads in my batch."
|
|
5165
|
+
- "I have a CSV of 400 attendees to import."
|
|
5166
|
+
|
|
5167
|
+
## RENDER (quick)
|
|
5168
|
+
|
|
5169
|
+
3-col table for delivered items (fit bar + company / why-fits \u226420 words /
|
|
5170
|
+
contact + channels) in returned order, then a compact Ref \u2192 Outcome table
|
|
5171
|
+
for skipped refs (not_in_universe, low_confidence_identity, ... in plain
|
|
5172
|
+
words), then the honest funnel + cost line. Full algorithm below.
|
|
5173
|
+
|
|
5174
|
+
---
|
|
5175
|
+
|
|
5176
|
+
Submit a qualify batch over companies the org already has (or that exist in
|
|
5177
|
+
the Leadbay universe): each ref is resolved to a known company, freshly
|
|
5178
|
+
researched + AI-qualified against the org's questions / tags / ideal buyer
|
|
5179
|
+
profile (frozen at submit), matched to the requested contact titles, and \u2014
|
|
5180
|
+
when asked \u2014 enriched with verified channels. Answers arrive per-item from a
|
|
5181
|
+
job; this tool polls up to \`wait_seconds\` (default 45) and hands off to
|
|
5182
|
+
\`leadbay_lead_job_status\` when the batch needs longer.
|
|
5183
|
+
|
|
5184
|
+
**Refs are flexible; outcomes are per-item.** \`lead_refs\` accepts any mix of
|
|
5185
|
+
\`lead_id\`, \`website\`, \`name\`(+\`location\`), or a stable \`contact_id\` from a
|
|
5186
|
+
prior result (enrichment then targets exactly that person, never a re-match).
|
|
5187
|
+
\`prior_deliveries\` expands past MCP deliveries into refs \u2014 billed leads stay
|
|
5188
|
+
re-readable this way even after the 30-day result window. Duplicates collapse.
|
|
5189
|
+
A ref that can't be served comes back \`skipped\` with an honest
|
|
5190
|
+
\`status_reason\` (\`not_in_universe\`, \`low_confidence_identity\` with the
|
|
5191
|
+
\`resolution.alternatives\` to choose from, \`no_matching_contact\`, ...) \u2014 that
|
|
5192
|
+
is an ANSWER about the ref, not an error, and it costs nothing.
|
|
5193
|
+
|
|
5194
|
+
**Disqualified \u2260 dropped.** Companies the org owns that fail qualification
|
|
5195
|
+
are DELIVERED with their negative evidence (question verdicts, tag misses,
|
|
5196
|
+
IBP reasoning) \u2014 "here's why to skip this account" is a deliverable.
|
|
5197
|
+
|
|
5198
|
+
**Cost \u2014 never spend silently.** Resolution and identity are free.
|
|
5199
|
+
\`qualify: true\` (the default) costs ~94 cost_cents per lead needing FRESH
|
|
5200
|
+
research+scoring \u2014 but repeat calls reuse every fresh cached stage
|
|
5201
|
+
(\`from_cache\` flags on the items) and converge to near-zero cost. \`channels\`
|
|
5202
|
+
purchase verified email (25c) / phone (250c) on success only;
|
|
5203
|
+
\`already_owned\` values cost nothing.
|
|
5204
|
+
|
|
5205
|
+
The gate is enforced in code, not just here: a PAID call (\`qualify\` left at
|
|
5206
|
+
its default or set true, and/or any \`channels\`) is WITHHELD unless it carries
|
|
5207
|
+
\`confirm: true\`. Without it the tool submits nothing and returns
|
|
5208
|
+
\`mode: "needs_confirmation"\` with a real backend quote \u2014 show that quote to
|
|
5209
|
+
the user, get the go-ahead (an explicit "spend / get their emails" in their
|
|
5210
|
+
message counts), then re-call with \`confirm: true\`. \`confirm: false\` is a
|
|
5211
|
+
veto: nothing is submitted and no quote round-trip is made. A fully FREE
|
|
5212
|
+
call (\`qualify: false\`, no \`channels\`) needs no \`confirm\` and passes straight
|
|
5213
|
+
through. Set \`request_id\` and reuse it on retries of the same batch.
|
|
5214
|
+
|
|
5215
|
+
**Limits**: 500 refs/job, 3 active jobs/org, 10 submits/hour (429 +
|
|
5216
|
+
Retry-After beyond \u2014 wait, don't hammer), 30-min job wall clock.
|
|
5217
|
+
|
|
5218
|
+
---
|
|
5219
|
+
|
|
5220
|
+
## RENDERING \u2014 delivery table + honest funnel line
|
|
5221
|
+
|
|
5222
|
+
Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or
|
|
5223
|
+
\`degraded\`) as a markdown table **in the order returned**. Exactly three
|
|
5224
|
+
columns. Then ALWAYS close with the funnel line (below) \u2014 even, especially,
|
|
5225
|
+
when nothing was delivered.
|
|
5226
|
+
|
|
5227
|
+
**Column 1 \u2014 Company**
|
|
5228
|
+
|
|
5229
|
+
- Line 1: 10-segment fit bar in inline-code backticks from \`lead.fit.score\`
|
|
5230
|
+
(0-100): \`filled = round(score/10)\`, glyphs \`\u25B0\` filled / \`\u25B1\` empty. When
|
|
5231
|
+
\`lead.fit.components.qualification.available\` is true AND \`ai_score > 0\`,
|
|
5232
|
+
replace the LAST filled segment with \`\u2756\` (AI-confirmed cap). When
|
|
5233
|
+
\`fit.available\` is false, render \`\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\` and say "unscored" in col 2.
|
|
5234
|
+
Never print the numeric score.
|
|
5235
|
+
- Insert \`<br>\`, then: linked company name (target \`company.website\`, bare
|
|
5236
|
+
hostnames get \`https://\`; unlinked plain text when absent) + \` \xB7 \` + short
|
|
5237
|
+
location (City, ST / City, Country) + \` \xB7 \` + employees as \`min\u2013max\` (omit
|
|
5238
|
+
when \`employees.known\` is false).
|
|
5239
|
+
|
|
5240
|
+
**Column 2 \u2014 Why it fits**
|
|
5241
|
+
|
|
5242
|
+
- One sentence \u2264 20 words. Priority: \`fit.reasoning\` \u2192 gist of
|
|
5243
|
+
\`company.description\` \u2192 top \`fit.components.qualification.matched_tags\`.
|
|
5244
|
+
- If the item status is \`degraded\` or a requested channel failed, append the
|
|
5245
|
+
honest flag in italics, e.g. *(email could not be sourced)*.
|
|
5246
|
+
|
|
5247
|
+
**Column 3 \u2014 Contact**
|
|
5248
|
+
|
|
5249
|
+
- \`[Name](linkedin) \xB7 role\` (linked name mandatory when a LinkedIn URL
|
|
5250
|
+
exists; plain name otherwise). Below it, the PURCHASED channels only:
|
|
5251
|
+
\`\u2709 value\` / \`\u260E value\` inline as plain text (they auto-linkify).
|
|
5252
|
+
- Channel statuses: \`delivered\` \u2192 show value; \`already_owned\` \u2192 value +
|
|
5253
|
+
*(already yours)*; \`masked\` \u2192 "on file \u2014 reveal via channels";
|
|
5254
|
+
\`not_requested\` \u2192 omit; \`failed_*\` \u2192 *(no verified email/phone)*.
|
|
5255
|
+
- No contact on the item (\`contact\` null): render \`\u2014\` (title_gate \`prefer\`
|
|
5256
|
+
delivers such rows flagged; say so in col 2 only when contact_titles were
|
|
5257
|
+
requested).
|
|
5258
|
+
|
|
5259
|
+
**The funnel line (mandatory, after the table):**
|
|
5260
|
+
|
|
5261
|
+
One short line narrating the delivery honestly, from \`funnel\` + \`cost\` +
|
|
5262
|
+
\`explain.scope_notes\`:
|
|
5263
|
+
|
|
5264
|
+
> Matched N \xB7 examined E \xB7 qualified Q \xB7 disqualified D \u2192 **delivered X of
|
|
5265
|
+
> the Y asked** \xB7 stopped: <stop_reason in plain words> \xB7 spent C.CC.
|
|
5266
|
+
|
|
5267
|
+
**Money: divide, then symbol.** Every amount (\`cost.spent\`,
|
|
5268
|
+
\`estimated_cost.max\`, quotes) is \`cost_cents\` \u2014 divide by 100, two decimals,
|
|
5269
|
+
so \`165\` renders \`1.65\`, NEVER \`165.00\`. Symbol from the account region: US
|
|
5270
|
+
\`$\`, France \`\u20AC\`, unknown \u2192 bare. Never hard-code \`$\`: it misstates a charge.
|
|
5271
|
+
|
|
5272
|
+
"of the Y asked" needs \`summary.items_requested\`, which submits carry but a
|
|
5273
|
+
later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered
|
|
5274
|
+
X** and stop \u2014 never back-fill Y from \`matched\`/\`examined\` (they count
|
|
5275
|
+
candidates), never guess it.
|
|
5276
|
+
|
|
5277
|
+
Plain-word stop reasons: \`target_reached\` \u2192 omit (success), \`pool_exhausted\` \u2192
|
|
5278
|
+
"ran out of matching candidates", \`max_cost\` \u2192 "hit the cost cap", \`quota\` \u2192
|
|
5279
|
+
"hit an org quota", \`time_budget\` \u2192 "hit the 30-min time budget".
|
|
5280
|
+
|
|
5281
|
+
**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give
|
|
5282
|
+
the funnel line plus the relevant \`explain.scope_notes\` (the backend's own
|
|
5283
|
+
diagnosis), then propose the concrete fix (reshape the seed per the craft
|
|
5284
|
+
rules, lower \`min_ai_score\`, raise \`max_cost\`, drop a filter) as NEXT STEPS.
|
|
5285
|
+
|
|
5286
|
+
**Weak batch**: when the BEST delivered \`fit.score\` is under 30, don't present
|
|
5287
|
+
the table as an answer \u2014 open with "weak matches only", show at most the top 3,
|
|
5288
|
+
propose reshaping the seed/filters first. The count was filled with
|
|
5289
|
+
barely-better-than-random candidates.
|
|
5290
|
+
|
|
5291
|
+
**Sanity-check every row**: (a) geo \u2014 \`city\`/\`region\` must sit inside any
|
|
5292
|
+
requested fence; drop and call out leaks (same-named cities slip through).
|
|
5293
|
+
(b) When \`explain.seed_strategy\` is \`text_match_exemplars\` (the standard FR
|
|
5294
|
+
path), fit is calibrated for lead-to-lead distances, not exemplar centroids \u2014
|
|
5295
|
+
treat high scores skeptically and verify each row's \`description\`.
|
|
5296
|
+
|
|
5297
|
+
**Skipped items** (\`skipped[]\`, qualify jobs mostly): render a compact second
|
|
5298
|
+
table \`Ref \u2192 Outcome\` translating \`status_reason\` to plain words:
|
|
5299
|
+
\`not_in_universe\` \u2192 "not in the Leadbay universe (import it first)",
|
|
5300
|
+
\`low_confidence_identity\` \u2192 "couldn't safely match \u2014 check \`resolution.alternatives\`",
|
|
5301
|
+
\`no_matching_contact\` \u2192 "no contact with the requested title",
|
|
5302
|
+
\`disqualified\` \u2192 "evaluated: does not fit" (evidence is in the item when owned),
|
|
5303
|
+
\`enrichment_failed\` \u2192 "channel could not be sourced (not billed)".
|
|
5304
|
+
|
|
5305
|
+
**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer
|
|
5306
|
+
\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest.
|
|
5307
|
+
|
|
5308
|
+
**Hide from the user:** UUIDs (keep for tool calls, never render), cursors,
|
|
5309
|
+
\`explain.model\`/\`intelligence_snapshot\`, raw \`distance\`/\`calibration\`,
|
|
5310
|
+
\`seq\`/\`from_cache\`, empty arrays.
|
|
5311
|
+
|
|
5312
|
+
## Linking a contact's name
|
|
5313
|
+
|
|
5314
|
+
**MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
|
|
5315
|
+
|
|
5316
|
+
URL priority (first applicable wins):
|
|
5317
|
+
|
|
5318
|
+
1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
|
|
5319
|
+
2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
|
|
5320
|
+
|
|
5321
|
+
Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
|
|
5322
|
+
|
|
5323
|
+
|
|
5324
|
+
|
|
5325
|
+
**Delivered \u2260 endorsed.** This tool DELIVERS org-owned companies that FAILED
|
|
5326
|
+
qualification, carrying their negative evidence \u2014 so a delivered item is not
|
|
5327
|
+
automatically a prospect. An item whose \`status_reason\` is \`disqualified\`, or
|
|
5328
|
+
whose \`fit.components.qualification\` is available with a negative \`ai_score\`,
|
|
5329
|
+
must NOT go in the fit table: its firmographic score can still be high, and a
|
|
5330
|
+
full bar beside "why it fits" reads as a recommendation to call an account the
|
|
5331
|
+
evaluation just rejected.
|
|
5332
|
+
|
|
5333
|
+
Give those their own short section after the fit table, titled
|
|
5334
|
+
**Evaluated \u2014 does not fit**: linked company, then the verdict in plain
|
|
5335
|
+
words from the
|
|
5336
|
+
qualification evidence (failed question verdicts, missed tags, IBP reasoning).
|
|
5337
|
+
That is the deliverable \u2014 "here's why to skip this account" \u2014 not a defect to
|
|
5338
|
+
hide.
|
|
5339
|
+
|
|
5340
|
+
|
|
5341
|
+
---
|
|
5342
|
+
|
|
5343
|
+
## NEXT STEPS \u2014 after a qualify_leads delivery
|
|
5344
|
+
|
|
5345
|
+
**ALWAYS render NEXT STEPS via your host's next-step widget.** Use whichever is in your tool set \u2014 the NAME and SCHEMA differ: **\`ask_user_input_v0\`** (Claude chat / ChatGPT) takes plain-string options with \`type:"single_select"\`; **\`AskUserQuestion\`** (Claude cowork / Claude Code) takes object options \`{label, description}\` plus a required short \`header\` (\u226412 chars) and \`multiSelect\`, NO \`type\` field, and never add an "Other" option (the host adds it). Match the schema to the tool you actually have \u2014 the wrong schema fails silently and you fall back to prose. Prose bullets are the fallback ONLY when NEITHER widget exists. Any turn that would end with a choice must be the widget \u2014 the widget IS the question.
|
|
5346
|
+
|
|
5347
|
+
**If the tool result carries a \`next_steps\` object, that is the source of truth \u2014 use it directly.** Each option has a short \`.label\` (\u22645 words) and a full \`.description\`. Map \`next_steps.options[]\` into your host widget VERBATIM and in order: for \`AskUserQuestion\` (cowork / Claude Code) pass each as \`{label, description}\`; for \`ask_user_input_v0\` (Claude chat / ChatGPT, string options only) pass each option's \`.description\` as the string (it's the full sentence). Do NOT reword, reorder, drop, or prose-ify them \u2014 they're built deterministically by the server so the offer (incl. the artifact option at position 0) fires every time. Fall back to the table below only when there is NO \`next_steps\` field.
|
|
5348
|
+
|
|
5349
|
+
**One exception \u2014 skip the widget** when the user's original message contained a complete sequential instruction chain ("show me X and then do Y") AND all stated steps have been completed. In that case, end with STOP directly \u2014 the user stated their full plan and does not need a "what next?" prompt.
|
|
5350
|
+
- Skip example: "Show me today's leads and then research the top one for me." \u2192 after research completes, emit STOP without the widget.
|
|
5351
|
+
- Do NOT skip for: plain requests ("show me today's leads", "run my check-in"), recurring-language requests ("I do this every day"), or requests where only one action was stated.
|
|
5352
|
+
|
|
5353
|
+
Pick 2\u20134 rows from the (Observation, Suggest, Calls) table below most relevant to the response, then call your host's widget with ITS schema (per the schema rules above \u2014 wrong schema fails silently):
|
|
5354
|
+
- \`ask_user_input_v0\`: \`{questions:[{question,type:"single_select",options:["<Suggest 1>","<Suggest 2>"]}]}\`
|
|
5355
|
+
- \`AskUserQuestion\`: \`{questions:[{question,header:"Next step",multiSelect:false,options:[{label:"<\u22645 words>",description:"<Suggest 1>"}]}]}\`
|
|
5356
|
+
|
|
5357
|
+
User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutually-exclusive options, AskUserQuestion labels \u22645 words (full text in \`description\`), max 3 questions. Table stays internal; never recite it.
|
|
5358
|
+
|
|
5359
|
+
---
|
|
5360
|
+
|
|
5361
|
+
|
|
5362
|
+
|
|
5363
|
+
Pick the 2-3 options that match what actually happened:
|
|
5364
|
+
|
|
5365
|
+
| Observation | Suggest | Calls |
|
|
5366
|
+
|---|---|---|
|
|
5367
|
+
| Job still running | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
|
|
5368
|
+
| Fit leads with contacts delivered | "Draft outreach for the qualified ones" | leadbay_prepare_outreach |
|
|
5369
|
+
| Items skipped \`not_in_universe\` | "Import those companies first, then re-qualify" | leadbay_import_leads \u2192 leadbay_qualify_leads |
|
|
5370
|
+
| Items skipped \`low_confidence_identity\` | "Pick the right match" (show \`resolution.alternatives\`) | leadbay_qualify_leads with the chosen lead_id |
|
|
5371
|
+
| Contacts delivered without channels | "Purchase verified emails/phones for the keepers (state cost first)" | leadbay_qualify_leads(lead_refs with contact_id, channels) |
|
|
5372
|
+
| Disqualified with evidence | "Review why \u2014 adjust qualification questions if the criteria are off" | leadbay_get_qualification_questions |
|
|
4552
5373
|
`;
|
|
4553
5374
|
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:
|
|
4554
5375
|
|
|
@@ -6128,7 +6949,7 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
|
|
|
6128
6949
|
NO_COMMERCE_TOOL_DESCRIPTIONS = {
|
|
6129
6950
|
leadbay_account_status: `## WHEN TO USE
|
|
6130
6951
|
|
|
6131
|
-
Trigger phrases: "what's my account status", "how much quota do I have", "what lens am I on", "I topped up / I bought credits / I added credits".
|
|
6952
|
+
Trigger phrases: "what's my account status", "how much quota do I have", "what lens am I on", "I topped up / I bought credits / I added credits", "what version of Leadbay am I running".
|
|
6132
6953
|
|
|
6133
6954
|
Do NOT use for: "show me leads" \u2192 \`leadbay_pull_leads\`.
|
|
6134
6955
|
|
|
@@ -6137,6 +6958,7 @@ Prefer when: meta question about account, quota, active lens, or top-up recovery
|
|
|
6137
6958
|
Examples that SHOULD invoke this tool:
|
|
6138
6959
|
- "What's my account status?"
|
|
6139
6960
|
- "How much quota do I have left this week?"
|
|
6961
|
+
- "Which version of the Leadbay connector is this?"
|
|
6140
6962
|
|
|
6141
6963
|
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
6142
6964
|
- "Show me today's leads."
|
|
@@ -6158,6 +6980,8 @@ Show the user's account state \u2014 admin rights, language, last-active lens, q
|
|
|
6158
6980
|
|
|
6159
6981
|
**After a user tops up, do NOT keep refusing \u2014 RETRY.** If the user signals they topped up / bought credits / added credits, the previous QUOTA_EXCEEDED is invalidated the moment the Stripe webhook lands. RE-CALL \`leadbay_account_status\` to pick up the new state AND retry the originally failed call. The retry itself does not require a successful account_status check first \u2014 a topped-up user has cleared the throttle whether or not your cached snapshot reflects it yet. **A stale quota snapshot is never a reason to gate-keep a topped-up user.**
|
|
6160
6982
|
|
|
6983
|
+
**\`mcp_version\`** is the version of the Leadbay MCP server answering the call. When the user asks which Leadbay version they are running, answer with it.
|
|
6984
|
+
|
|
6161
6985
|
**\`notifications\` block.** The response now includes a top-level \`notifications\` array listing background work the user (or agent) initiated that has since completed (\`bulk_enrich\`, \`bulk_qualify\`, \`import\`). These are signals to revise prior agent outputs the just-finished work might have made stale \u2014 they're NOT a pending-task list for the user. After revising (or confirming nothing is affected), call \`leadbay_acknowledge_notification(notification_id)\`. Full handling protocol below.
|
|
6162
6986
|
|
|
6163
6987
|
## GATE \u2014 INSPECT \`_meta.notifications\` ON EVERY RESPONSE
|
|
@@ -6279,42 +7103,291 @@ WHEN TO USE: at the start of a session to know what the agent can/can't do, afte
|
|
|
6279
7103
|
|
|
6280
7104
|
WHEN NOT TO USE: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating. And: a recent quota snapshot showing "exhausted" is NOT a reason to refuse a write call when the user has just topped up \u2014 re-call this tool first, then proceed.
|
|
6281
7105
|
`,
|
|
6282
|
-
|
|
7106
|
+
leadbay_find_new_leads: `## WHEN TO USE
|
|
6283
7107
|
|
|
6284
|
-
Trigger phrases: "
|
|
7108
|
+
Trigger phrases: "find me N companies that <profile>", "get me new prospects like <company>", "I need leads in <place> that <do X>", "search for companies that would buy <product>", "net-new leads outside my current pipeline", "we're entering <market> \u2014 who should we target".
|
|
6285
7109
|
|
|
6286
|
-
Do NOT use for: "
|
|
7110
|
+
Do NOT use for: "show me today's leads / what's new today" \u2192 \`leadbay_pull_leads\`; "find me new leads (no profile, no count named)" \u2192 \`leadbay_pull_leads\`; "more leads like the ones in my lens" \u2192 \`leadbay_extend_lens\`; "qualify / vet these companies I have" \u2192 \`leadbay_qualify_leads\`; "qualify the top N of my batch" \u2192 \`leadbay_bulk_qualify_leads\`; "leads I should follow up with" \u2192 \`leadbay_pull_followups\`; "tell me about <one company>" \u2192 \`leadbay_research_lead_by_name_fuzzy\`.
|
|
6287
7111
|
|
|
6288
|
-
Prefer when: user
|
|
7112
|
+
Prefer when: the user describes a target profile or names a count of NEW companies \u2014 craft the example_lead per the seed rules below BEFORE calling; never pass the user's raw sentence as query.
|
|
6289
7113
|
|
|
6290
7114
|
Examples that SHOULD invoke this tool:
|
|
6291
|
-
- "
|
|
6292
|
-
- "
|
|
6293
|
-
- "
|
|
7115
|
+
- "Find me 10 gyms around Dallas that would buy our flooring, with someone I can call."
|
|
7116
|
+
- "Get me 20 new US SaaS companies, 50-2000 employees, with the VP People's email."
|
|
7117
|
+
- "We're launching in Lyon \u2014 find 15 hotels that fit our ICP."
|
|
6294
7118
|
|
|
6295
7119
|
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
6296
|
-
- "
|
|
6297
|
-
- "
|
|
6298
|
-
- "Qualify
|
|
7120
|
+
- "Show me today's leads."
|
|
7121
|
+
- "Which leads should I follow up with this week?"
|
|
7122
|
+
- "Qualify these 40 websites from my spreadsheet."
|
|
6299
7123
|
|
|
6300
7124
|
## RENDER (quick)
|
|
6301
7125
|
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
7126
|
+
3-col table of delivered leads in returned order: col 1 = 10-segment fit
|
|
7127
|
+
bar + linked company \xB7 location \xB7 size; col 2 = why-fits \u226420 words; col 3
|
|
7128
|
+
= contact + purchased channels. ALWAYS close with the honest funnel line
|
|
7129
|
+
(matched/examined/delivered/stop reason/spend) \u2014 especially on 0
|
|
7130
|
+
delivered. Full algorithm below.
|
|
6307
7131
|
|
|
6308
7132
|
---
|
|
6309
7133
|
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
profile
|
|
7134
|
+
Submit a net-new lead search: the backend matches an ICP seed against the full
|
|
7135
|
+
company universe, applies hard filters, skips what the org already knows
|
|
7136
|
+
(\`novelty: org\`), optionally qualifies against the org's own intelligence
|
|
7137
|
+
(questions, tags, ideal buyer profile \u2014 frozen at submit), and optionally buys
|
|
7138
|
+
contact channels. Polls up to \`wait_seconds\` (default 45); a longer job returns
|
|
7139
|
+
\`still_running\` + \`next_poll\` \u2014 hand off to \`leadbay_lead_job_status\`. Jobs run
|
|
7140
|
+
\u226430 min, results kept 30 days.
|
|
7141
|
+
|
|
7142
|
+
**Free vs paid \u2014 never spend silently.** Default (\`qualify: false\`,
|
|
7143
|
+
\`channels: []\`) is FREE: company profile + fit score + cached research +
|
|
7144
|
+
contact identity. Paid: \`qualify: true\` (~94 cost_cents per candidate
|
|
7145
|
+
EXAMINED, capped by \`exploration_cap\`/\`max_cost\`) and \`channels\` (email 25c /
|
|
7146
|
+
phone 250c, success-only). Enforced in code: a paid call is WITHHELD unless it
|
|
7147
|
+
carries \`confirm: true\` \u2014 nothing is submitted and you get
|
|
7148
|
+
\`mode: "needs_confirmation"\` with a real quote to show the user. Re-call with
|
|
7149
|
+
\`confirm: true\` on their go-ahead ("spend / get their emails" counts).
|
|
7150
|
+
\`confirm: false\` vetoes. Free needs no consent. **Preview free first** \u2014
|
|
7151
|
+
reshaping an off-profile seed is free, exploring it with \`qualify: true\` is
|
|
7152
|
+
not.
|
|
7153
|
+
|
|
7154
|
+
**Ad-hoc exclusions ("no chains") are enforced by NO tier** \u2014 \`filters\` has no
|
|
7155
|
+
exclusion key, and \`qualify\` scores against the org's FROZEN questions and IBP,
|
|
7156
|
+
which need not mention chains; the seed's inverse only shifts ranking.
|
|
7157
|
+
Violators can survive, be paid for and be delivered \u2014 post-filter them yourself
|
|
7158
|
+
and say the tier didn't enforce it. Durable enforcement \u2192
|
|
7159
|
+
\`leadbay_refine_prompt\`.
|
|
7160
|
+
|
|
7161
|
+
### Crafting the \`example_lead\` seed \u2014 the input that decides result quality
|
|
7162
|
+
|
|
7163
|
+
The \`example_lead\` is a FICTIONAL typical ideal customer, matched against real
|
|
7164
|
+
registry/website descriptions \u2014 which state what a company **IS**, never what
|
|
7165
|
+
is happening. Write it the same way or the matcher drifts. Every rule below is
|
|
7166
|
+
measured:
|
|
7167
|
+
|
|
7168
|
+
1. **Describe the BUYER, never the seller.** Ask: "would this company write a
|
|
7169
|
+
check to my user?" A seed describing what the user SELLS surfaces their
|
|
7170
|
+
*competitors and vendors*. If the product helps companies of type X serve
|
|
7171
|
+
customers of type Y, the seed describes X \u2014 never Y.
|
|
7172
|
+
2. **Put everything in \`description\`; leave \`name\` unset.** An invented brand
|
|
7173
|
+
name pulls matching toward name-lookalikes \u2014 a seed named "Meridian
|
|
7174
|
+
Analytics" returned five unrelated "Meridian" companies.
|
|
7175
|
+
3. **Registry style, one sentence to ~250 chars.** Industry niche, business
|
|
7176
|
+
model, what they sell or operate, who they serve, observable scale. Write
|
|
7177
|
+
it like the first paragraph of their About-Us page.
|
|
7178
|
+
- STRONG: "Operator of full-service fitness centers offering strength
|
|
7179
|
+
areas, group classes and personal training to members across multiple
|
|
7180
|
+
clubs."
|
|
7181
|
+
- WEAK (generic): "A gym in Texas."
|
|
7182
|
+
- WRONG (seller-side): "Supplier of durable modular flooring for gyms."
|
|
7183
|
+
4. **No event language.** "hiring", "expanding", "just raised" are not
|
|
7184
|
+
filters \u2014 registry descriptions never contain them, so they dilute the
|
|
7185
|
+
profile. Purchase triggers belong in the org's qualification questions.
|
|
7186
|
+
5. **No meta-markers.** Never "(example)", "(fictional)", "(placeholder)".
|
|
7187
|
+
6. **Hard constraints go in \`filters\`, not prose \u2014 exact keys:**
|
|
7188
|
+
\`sectors: string[]\`, \`locations: string[]\`, \`employees_min: number\`,
|
|
7189
|
+
\`employees_max: number\`. FLAT numbers \u2014 nested \`employees: {min, max}\`
|
|
7190
|
+
exists only in RESULT payloads. \`example_lead.employees\` does not filter.
|
|
7191
|
+
\`locations\` take city/state/region names ("Dallas, TX", "\xCEle-de-France");
|
|
7192
|
+
a country name is refused in code \u2014 whole-country intent = omit it.
|
|
7193
|
+
7. **Prefer \`example_lead\` over \`query\`.** Query matches topic *vocabulary*:
|
|
7194
|
+
"gyms that need durable flooring" surfaced flooring VENDORS, 0 delivered.
|
|
7195
|
+
Use \`query\` only for signal an example can't express.
|
|
7196
|
+
8. **One seed per buyer archetype.** An ask spanning two segments ("gyms and
|
|
7197
|
+
warehouses") needs one search each with its own description and
|
|
7198
|
+
\`request_id\` \u2014 a blended seed lands between the clusters and matches
|
|
7199
|
+
neither.
|
|
7200
|
+
|
|
7201
|
+
|
|
7202
|
+
**Parameter notes**
|
|
7203
|
+
- \`request_id\` (REQUIRED) is the retry contract: SAME value retrying the same
|
|
7204
|
+
ask (same live job, no double spend); NEW for a changed ask. Derive from ask
|
|
7205
|
+
+ archetype + date: \`gyms-dallas-2026-07-28\`.
|
|
7206
|
+
- Never lower \`min_ai_score\` together with \`channels\` \u2014 that buys emails for
|
|
7207
|
+
leads the AI just scored as junk.
|
|
7208
|
+
- \`count\` \u2264 50; \u22643 active jobs/org; \u226410 submits/hour (429 + Retry-After \u2014
|
|
7209
|
+
wait, don't hammer).
|
|
7210
|
+
|
|
7211
|
+
**Read the result honestly** \u2014 \`funnel\` + \`explain.scope_notes\` tell the story;
|
|
7212
|
+
zero delivered gets a cause and a next move (rules in RENDERING).
|
|
6314
7213
|
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
7214
|
+
---
|
|
7215
|
+
|
|
7216
|
+
## RENDERING \u2014 delivery table + honest funnel line
|
|
7217
|
+
|
|
7218
|
+
Render delivered leads (\`leads[]\`, i.e. items with status \`delivered\` or
|
|
7219
|
+
\`degraded\`) as a markdown table **in the order returned**. Exactly three
|
|
7220
|
+
columns. Then ALWAYS close with the funnel line (below) \u2014 even, especially,
|
|
7221
|
+
when nothing was delivered.
|
|
7222
|
+
|
|
7223
|
+
**Column 1 \u2014 Company**
|
|
7224
|
+
|
|
7225
|
+
- Line 1: 10-segment fit bar in inline-code backticks from \`lead.fit.score\`
|
|
7226
|
+
(0-100): \`filled = round(score/10)\`, glyphs \`\u25B0\` filled / \`\u25B1\` empty. When
|
|
7227
|
+
\`lead.fit.components.qualification.available\` is true AND \`ai_score > 0\`,
|
|
7228
|
+
replace the LAST filled segment with \`\u2756\` (AI-confirmed cap). When
|
|
7229
|
+
\`fit.available\` is false, render \`\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\u25B1\` and say "unscored" in col 2.
|
|
7230
|
+
Never print the numeric score.
|
|
7231
|
+
- Insert \`<br>\`, then: linked company name (target \`company.website\`, bare
|
|
7232
|
+
hostnames get \`https://\`; unlinked plain text when absent) + \` \xB7 \` + short
|
|
7233
|
+
location (City, ST / City, Country) + \` \xB7 \` + employees as \`min\u2013max\` (omit
|
|
7234
|
+
when \`employees.known\` is false).
|
|
7235
|
+
|
|
7236
|
+
**Column 2 \u2014 Why it fits**
|
|
7237
|
+
|
|
7238
|
+
- One sentence \u2264 20 words. Priority: \`fit.reasoning\` \u2192 gist of
|
|
7239
|
+
\`company.description\` \u2192 top \`fit.components.qualification.matched_tags\`.
|
|
7240
|
+
- If the item status is \`degraded\` or a requested channel failed, append the
|
|
7241
|
+
honest flag in italics, e.g. *(email could not be sourced)*.
|
|
7242
|
+
|
|
7243
|
+
**Column 3 \u2014 Contact**
|
|
7244
|
+
|
|
7245
|
+
- \`[Name](linkedin) \xB7 role\` (linked name mandatory when a LinkedIn URL
|
|
7246
|
+
exists; plain name otherwise). Below it, the PURCHASED channels only:
|
|
7247
|
+
\`\u2709 value\` / \`\u260E value\` inline as plain text (they auto-linkify).
|
|
7248
|
+
- Channel statuses: \`delivered\` \u2192 show value; \`already_owned\` \u2192 value +
|
|
7249
|
+
*(already yours)*; \`masked\` \u2192 "on file \u2014 reveal via channels";
|
|
7250
|
+
\`not_requested\` \u2192 omit; \`failed_*\` \u2192 *(no verified email/phone)*.
|
|
7251
|
+
- No contact on the item (\`contact\` null): render \`\u2014\` (title_gate \`prefer\`
|
|
7252
|
+
delivers such rows flagged; say so in col 2 only when contact_titles were
|
|
7253
|
+
requested).
|
|
7254
|
+
|
|
7255
|
+
**The funnel line (mandatory, after the table):**
|
|
7256
|
+
|
|
7257
|
+
One short line narrating the delivery honestly, from \`funnel\` + \`cost\` +
|
|
7258
|
+
\`explain.scope_notes\`:
|
|
7259
|
+
|
|
7260
|
+
> Matched N \xB7 examined E \xB7 qualified Q \xB7 disqualified D \u2192 **delivered X of
|
|
7261
|
+
> the Y asked** \xB7 stopped: <stop_reason in plain words> \xB7 spent C.CC.
|
|
7262
|
+
|
|
7263
|
+
**Money: divide, then symbol.** Every amount (\`cost.spent\`,
|
|
7264
|
+
\`estimated_cost.max\`, quotes) is \`cost_cents\` \u2014 divide by 100, two decimals,
|
|
7265
|
+
so \`165\` renders \`1.65\`, NEVER \`165.00\`. Symbol from the account region: US
|
|
7266
|
+
\`$\`, France \`\u20AC\`, unknown \u2192 bare. Never hard-code \`$\`: it misstates a charge.
|
|
7267
|
+
|
|
7268
|
+
"of the Y asked" needs \`summary.items_requested\`, which submits carry but a
|
|
7269
|
+
later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered
|
|
7270
|
+
X** and stop \u2014 never back-fill Y from \`matched\`/\`examined\` (they count
|
|
7271
|
+
candidates), never guess it.
|
|
7272
|
+
|
|
7273
|
+
Plain-word stop reasons: \`target_reached\` \u2192 omit (success), \`pool_exhausted\` \u2192
|
|
7274
|
+
"ran out of matching candidates", \`max_cost\` \u2192 "hit the cost cap", \`quota\` \u2192
|
|
7275
|
+
"hit an org quota", \`time_budget\` \u2192 "hit the 30-min time budget".
|
|
7276
|
+
|
|
7277
|
+
**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give
|
|
7278
|
+
the funnel line plus the relevant \`explain.scope_notes\` (the backend's own
|
|
7279
|
+
diagnosis), then propose the concrete fix (reshape the seed per the craft
|
|
7280
|
+
rules, lower \`min_ai_score\`, raise \`max_cost\`, drop a filter) as NEXT STEPS.
|
|
7281
|
+
|
|
7282
|
+
**Weak batch**: when the BEST delivered \`fit.score\` is under 30, don't present
|
|
7283
|
+
the table as an answer \u2014 open with "weak matches only", show at most the top 3,
|
|
7284
|
+
propose reshaping the seed/filters first. The count was filled with
|
|
7285
|
+
barely-better-than-random candidates.
|
|
7286
|
+
|
|
7287
|
+
**Sanity-check every row**: (a) geo \u2014 \`city\`/\`region\` must sit inside any
|
|
7288
|
+
requested fence; drop and call out leaks (same-named cities slip through).
|
|
7289
|
+
(b) When \`explain.seed_strategy\` is \`text_match_exemplars\` (the standard FR
|
|
7290
|
+
path), fit is calibrated for lead-to-lead distances, not exemplar centroids \u2014
|
|
7291
|
+
treat high scores skeptically and verify each row's \`description\`.
|
|
7292
|
+
|
|
7293
|
+
**Skipped items** (\`skipped[]\`, qualify jobs mostly): render a compact second
|
|
7294
|
+
table \`Ref \u2192 Outcome\` translating \`status_reason\` to plain words:
|
|
7295
|
+
\`not_in_universe\` \u2192 "not in the Leadbay universe (import it first)",
|
|
7296
|
+
\`low_confidence_identity\` \u2192 "couldn't safely match \u2014 check \`resolution.alternatives\`",
|
|
7297
|
+
\`no_matching_contact\` \u2192 "no contact with the requested title",
|
|
7298
|
+
\`disqualified\` \u2192 "evaluated: does not fit" (evidence is in the item when owned),
|
|
7299
|
+
\`enrichment_failed\` \u2192 "channel could not be sourced (not billed)".
|
|
7300
|
+
|
|
7301
|
+
**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer
|
|
7302
|
+
\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest.
|
|
7303
|
+
|
|
7304
|
+
**Hide from the user:** UUIDs (keep for tool calls, never render), cursors,
|
|
7305
|
+
\`explain.model\`/\`intelligence_snapshot\`, raw \`distance\`/\`calibration\`,
|
|
7306
|
+
\`seq\`/\`from_cache\`, empty arrays.
|
|
7307
|
+
|
|
7308
|
+
## Linking a contact's name
|
|
7309
|
+
|
|
7310
|
+
**MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
|
|
7311
|
+
|
|
7312
|
+
URL priority (first applicable wins):
|
|
7313
|
+
|
|
7314
|
+
1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
|
|
7315
|
+
2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
|
|
7316
|
+
|
|
7317
|
+
Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
|
|
7318
|
+
|
|
7319
|
+
|
|
7320
|
+
|
|
7321
|
+
---
|
|
7322
|
+
|
|
7323
|
+
## NEXT STEPS \u2014 after a find_new_leads delivery
|
|
7324
|
+
|
|
7325
|
+
**ALWAYS render NEXT STEPS via your host's next-step widget.** Use whichever is in your tool set \u2014 the NAME and SCHEMA differ: **\`ask_user_input_v0\`** (Claude chat / ChatGPT) takes plain-string options with \`type:"single_select"\`; **\`AskUserQuestion\`** (Claude cowork / Claude Code) takes object options \`{label, description}\` plus a required short \`header\` (\u226412 chars) and \`multiSelect\`, NO \`type\` field, and never add an "Other" option (the host adds it). Match the schema to the tool you actually have \u2014 the wrong schema fails silently and you fall back to prose. Prose bullets are the fallback ONLY when NEITHER widget exists. Any turn that would end with a choice must be the widget \u2014 the widget IS the question.
|
|
7326
|
+
|
|
7327
|
+
**If the tool result carries a \`next_steps\` object, that is the source of truth \u2014 use it directly.** Each option has a short \`.label\` (\u22645 words) and a full \`.description\`. Map \`next_steps.options[]\` into your host widget VERBATIM and in order: for \`AskUserQuestion\` (cowork / Claude Code) pass each as \`{label, description}\`; for \`ask_user_input_v0\` (Claude chat / ChatGPT, string options only) pass each option's \`.description\` as the string (it's the full sentence). Do NOT reword, reorder, drop, or prose-ify them \u2014 they're built deterministically by the server so the offer (incl. the artifact option at position 0) fires every time. Fall back to the table below only when there is NO \`next_steps\` field.
|
|
7328
|
+
|
|
7329
|
+
**One exception \u2014 skip the widget** when the user's original message contained a complete sequential instruction chain ("show me X and then do Y") AND all stated steps have been completed. In that case, end with STOP directly \u2014 the user stated their full plan and does not need a "what next?" prompt.
|
|
7330
|
+
- Skip example: "Show me today's leads and then research the top one for me." \u2192 after research completes, emit STOP without the widget.
|
|
7331
|
+
- Do NOT skip for: plain requests ("show me today's leads", "run my check-in"), recurring-language requests ("I do this every day"), or requests where only one action was stated.
|
|
7332
|
+
|
|
7333
|
+
Pick 2\u20134 rows from the (Observation, Suggest, Calls) table below most relevant to the response, then call your host's widget with ITS schema (per the schema rules above \u2014 wrong schema fails silently):
|
|
7334
|
+
- \`ask_user_input_v0\`: \`{questions:[{question,type:"single_select",options:["<Suggest 1>","<Suggest 2>"]}]}\`
|
|
7335
|
+
- \`AskUserQuestion\`: \`{questions:[{question,header:"Next step",multiSelect:false,options:[{label:"<\u22645 words>",description:"<Suggest 1>"}]}]}\`
|
|
7336
|
+
|
|
7337
|
+
User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutually-exclusive options, AskUserQuestion labels \u22645 words (full text in \`description\`), max 3 questions. Table stays internal; never recite it.
|
|
7338
|
+
|
|
7339
|
+
---
|
|
7340
|
+
|
|
7341
|
+
|
|
7342
|
+
|
|
7343
|
+
Pick the 2-3 options that match what actually happened \u2014 never all seven:
|
|
7344
|
+
|
|
7345
|
+
| Observation | Suggest | Calls |
|
|
7346
|
+
|---|---|---|
|
|
7347
|
+
| Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
|
|
7348
|
+
| Free run delivered on-profile leads | "Qualify these N against your criteria (paid \u2014 \`dry_run\` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) |
|
|
7349
|
+
| Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
|
|
7350
|
+
| Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
|
|
7351
|
+
| Stopped at cost cap (\`stop_reason: max_cost\`) | "Raise the cap to X and get the remaining N" \u2014 X in the account's currency per the funnel-line rule, never a hard-coded \`$\` | leadbay_find_new_leads, NEW request_id (same-id only dedupes onto a LIVE job) + higher max_cost + \`count\` = the SHORTFALL (\`items_requested\` \u2212 delivered), not the original + \`exclude_lead_ids\` = the examined-but-REJECTED ids (novelty covers delivered; these are what it misses \u2014 without them the rerun re-buys the same losers) |
|
|
7352
|
+
| Stopped on org quota (\`stop_reason: quota\`) | "Check which window is exhausted and when it resets" \u2014 never a re-run: it cannot clear an org quota and burns a submit slot to stop in the same place | leadbay_account_status |
|
|
7353
|
+
| User wants these tracked in Leadbay | "Add the keepers to a campaign" | leadbay_create_campaign / leadbay_add_leads_to_campaign |
|
|
7354
|
+
`,
|
|
7355
|
+
leadbay_scan_portfolio_signals: `## WHEN TO USE
|
|
7356
|
+
|
|
7357
|
+
Trigger phrases: "which of my leads <did X>", "find leads that <raised / acquired / hired / moved / changed CEO>", "scan my portfolio for <signal>", "identify all the ones that <event> since <date>", "who in Monitor has a <funding / M&A / hiring> signal", "build a campaign from leads with <signal>".
|
|
7358
|
+
|
|
7359
|
+
Do NOT use for: "research one named company" \u2192 \`leadbay_research_lead_by_name_fuzzy\`; "everything about lead <UUID>" \u2192 \`leadbay_research_lead_by_id\`; "qualify my next N leads (they aren't researched yet)" \u2192 \`leadbay_bulk_qualify_leads\`; "just list my follow-ups" \u2192 \`leadbay_pull_followups\`.
|
|
7360
|
+
|
|
7361
|
+
Prefer when: user wants to FILTER a known portfolio by a web-research signal in bulk \u2014 pass \`query\`, optionally \`since\`, \`city\`/\`set_filter\`, or \`leadIds\`; NEVER a country name in \`city\` \u2014 a whole-country ask means NO geo filter
|
|
7362
|
+
|
|
7363
|
+
Examples that SHOULD invoke this tool:
|
|
7364
|
+
- "Which of my leads acquired a company since 2025?"
|
|
7365
|
+
- "Scan my Lyon portfolio for funding signals."
|
|
7366
|
+
- "Find everyone in Monitor who changed CEO and build a campaign."
|
|
7367
|
+
|
|
7368
|
+
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
7369
|
+
- "Look up Acme Corp for me."
|
|
7370
|
+
- "Show me my follow-ups."
|
|
7371
|
+
- "Qualify my next 10 leads."
|
|
7372
|
+
|
|
7373
|
+
## RENDER (quick)
|
|
7374
|
+
|
|
7375
|
+
Cohort grouped by lead: one block per matched lead (name \xB7 location +
|
|
7376
|
+
its matched signal entries, hot first, source-linked). Open with
|
|
7377
|
+
"N match <query> (M scanned)"; ALWAYS close with an honesty footer \u2014
|
|
7378
|
+
"scanned N \xB7 matched M \xB7 K not yet researched". Never present
|
|
7379
|
+
not_researched leads as "no signal". Full layout below.
|
|
7380
|
+
|
|
7381
|
+
---
|
|
7382
|
+
|
|
7383
|
+
Scan a known portfolio for a specific web-research signal in one call. This is
|
|
7384
|
+
the bulk, read-only answer to "which of my leads have signal X" \u2014 the question
|
|
7385
|
+
that otherwise forces a per-lead \`leadbay_research_lead_by_id\` loop (one full
|
|
7386
|
+
profile call per lead, slow and quota-heavy).
|
|
7387
|
+
|
|
7388
|
+
**Reads CACHED signals only \u2014 does not trigger new research.** For each lead in
|
|
7389
|
+
scope it reads \`GET /leads/{id}/web_fetch\` (the already-computed web-research
|
|
7390
|
+
signals) and filters the entries against \`query\`. It issues NO web_fetch POST,
|
|
6318
7391
|
so it does not consume AI qualification credits and does not re-crawl. Leads
|
|
6319
7392
|
that have no cached content (never qualified, or still in progress) are
|
|
6320
7393
|
reported in \`not_researched\` \u2014 they are **NOT** silently treated as "no
|
|
@@ -13385,12 +14458,12 @@ var init_pull_leads = __esm({
|
|
|
13385
14458
|
});
|
|
13386
14459
|
|
|
13387
14460
|
// ../core/dist/composite/_geo-helpers.js
|
|
13388
|
-
function expandAlias(
|
|
13389
|
-
const key =
|
|
13390
|
-
return CITY_ALIASES[key] ??
|
|
14461
|
+
function expandAlias(text2) {
|
|
14462
|
+
const key = text2.trim().toLowerCase();
|
|
14463
|
+
return CITY_ALIASES[key] ?? text2;
|
|
13391
14464
|
}
|
|
13392
|
-
function scoreMatch(
|
|
13393
|
-
const t =
|
|
14465
|
+
function scoreMatch(text2, match) {
|
|
14466
|
+
const t = text2.trim().toLowerCase();
|
|
13394
14467
|
const n = match.name.trim().toLowerCase();
|
|
13395
14468
|
if (n === t)
|
|
13396
14469
|
return 1;
|
|
@@ -13413,8 +14486,8 @@ async function resolveLocations(client, texts) {
|
|
|
13413
14486
|
const resolved = [...direct];
|
|
13414
14487
|
const ambiguities = [];
|
|
13415
14488
|
for (const originalText of free) {
|
|
13416
|
-
const
|
|
13417
|
-
const path = `/geo/search?q=${encodeURIComponent(
|
|
14489
|
+
const text2 = expandAlias(originalText);
|
|
14490
|
+
const path = `/geo/search?q=${encodeURIComponent(text2)}`;
|
|
13418
14491
|
let response;
|
|
13419
14492
|
try {
|
|
13420
14493
|
response = await client.request("GET", path);
|
|
@@ -13453,7 +14526,7 @@ async function resolveLocations(client, texts) {
|
|
|
13453
14526
|
name: r.name,
|
|
13454
14527
|
country: r.country,
|
|
13455
14528
|
level: r.level,
|
|
13456
|
-
score: scoreMatch(
|
|
14529
|
+
score: scoreMatch(text2, r)
|
|
13457
14530
|
})).sort((a, b) => {
|
|
13458
14531
|
if (b.score !== a.score)
|
|
13459
14532
|
return b.score - a.score;
|
|
@@ -14723,9 +15796,9 @@ ${firm.short_description}`);
|
|
|
14723
15796
|
out.push(`### ${sec.section_emoji ?? ""} ${label}`.trim());
|
|
14724
15797
|
const entries = Array.isArray(sec.entries) ? sec.entries : [];
|
|
14725
15798
|
for (const e of entries.slice(0, 5)) {
|
|
14726
|
-
const
|
|
15799
|
+
const text2 = e.text ?? e.summary ?? JSON.stringify(e).slice(0, 200);
|
|
14727
15800
|
const hot = e.hot === true ? " \u{1F525}" : "";
|
|
14728
|
-
out.push(`- ${
|
|
15801
|
+
out.push(`- ${text2}${hot}`);
|
|
14729
15802
|
}
|
|
14730
15803
|
if (entries.length > 5)
|
|
14731
15804
|
out.push(`- _${entries.length - 5} more \u2026_`);
|
|
@@ -16729,6 +17802,11 @@ var init_account_status = __esm({
|
|
|
16729
17802
|
agent_memory: { type: "object" }
|
|
16730
17803
|
}
|
|
16731
17804
|
},
|
|
17805
|
+
// Set by the MCP server wrapper (NOT this composite) on every call.
|
|
17806
|
+
mcp_version: {
|
|
17807
|
+
type: "string",
|
|
17808
|
+
description: "Version of the Leadbay MCP server answering this call. Answer 'what version of Leadbay are you running' with this value."
|
|
17809
|
+
},
|
|
16732
17810
|
// Auto-update block. Populated by the MCP server wrapper (NOT this
|
|
16733
17811
|
// composite) when a newer release is published on GitHub AND the
|
|
16734
17812
|
// user hasn't suppressed it. When present, the agent should prompt
|
|
@@ -19455,8 +20533,8 @@ function tokens(s) {
|
|
|
19455
20533
|
return [];
|
|
19456
20534
|
return s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
19457
20535
|
}
|
|
19458
|
-
function bestMatches(
|
|
19459
|
-
const want = new Set(tokens(
|
|
20536
|
+
function bestMatches(text2, taxonomy) {
|
|
20537
|
+
const want = new Set(tokens(text2));
|
|
19460
20538
|
if (want.size === 0)
|
|
19461
20539
|
return [];
|
|
19462
20540
|
const ranked = taxonomy.map((s) => {
|
|
@@ -19485,12 +20563,12 @@ async function resolveSectors(client, texts, ctx) {
|
|
|
19485
20563
|
}
|
|
19486
20564
|
const resolved = [...direct];
|
|
19487
20565
|
const ambiguities = [];
|
|
19488
|
-
for (const
|
|
19489
|
-
const matches = bestMatches(
|
|
20566
|
+
for (const text2 of free) {
|
|
20567
|
+
const matches = bestMatches(text2, taxonomy);
|
|
19490
20568
|
if (matches.length === 1 || matches.length >= 2 && matches[0].score >= 0.66 && matches[0].score - matches[1].score >= 0.34) {
|
|
19491
20569
|
resolved.push(matches[0].id);
|
|
19492
20570
|
} else {
|
|
19493
|
-
ambiguities.push({ sector_text:
|
|
20571
|
+
ambiguities.push({ sector_text: text2, matches });
|
|
19494
20572
|
}
|
|
19495
20573
|
}
|
|
19496
20574
|
return { resolved, ambiguities };
|
|
@@ -19964,214 +21042,1462 @@ var init_refine_prompt = __esm({
|
|
|
19964
21042
|
required: ["prompt"],
|
|
19965
21043
|
additionalProperties: false
|
|
19966
21044
|
},
|
|
19967
|
-
outputSchema: {
|
|
21045
|
+
outputSchema: {
|
|
21046
|
+
type: "object",
|
|
21047
|
+
description: "Multiple return shapes by status. dry_run, applied (with optional clarified_via_elicit), or clarification_pending.",
|
|
21048
|
+
properties: {
|
|
21049
|
+
dry_run: { type: "boolean", description: "True when dry_run:true was passed (no state change)." },
|
|
21050
|
+
would_call: {
|
|
21051
|
+
type: "object",
|
|
21052
|
+
description: "Dry-run preview of the POST that would have been issued."
|
|
21053
|
+
},
|
|
21054
|
+
status: {
|
|
21055
|
+
type: "string",
|
|
21056
|
+
description: "'applied' (prompt set; intelligence regenerating) or 'clarification_pending' (telephone path)."
|
|
21057
|
+
},
|
|
21058
|
+
computing_intelligence: {
|
|
21059
|
+
type: "boolean",
|
|
21060
|
+
description: "True when intelligence is regenerating after the prompt set."
|
|
21061
|
+
},
|
|
21062
|
+
clarified_via_elicit: {
|
|
21063
|
+
type: "boolean",
|
|
21064
|
+
description: "True when the clarification was answered via the client's elicitation UI (not via telephone)."
|
|
21065
|
+
},
|
|
21066
|
+
message: {
|
|
21067
|
+
type: "string",
|
|
21068
|
+
description: "Operator-facing summary."
|
|
21069
|
+
},
|
|
21070
|
+
clarification: {
|
|
21071
|
+
type: "object",
|
|
21072
|
+
description: "ClarificationPayload returned by the backend (clarification_pending path)."
|
|
21073
|
+
},
|
|
21074
|
+
next_action: {
|
|
21075
|
+
type: "string",
|
|
21076
|
+
description: "Concrete next-step instruction for the agent."
|
|
21077
|
+
},
|
|
21078
|
+
_meta: { type: "object" }
|
|
21079
|
+
}
|
|
21080
|
+
},
|
|
21081
|
+
execute: async (client, params, ctx) => {
|
|
21082
|
+
const me = await client.resolveMe();
|
|
21083
|
+
if (me.admin !== true) {
|
|
21084
|
+
return {
|
|
21085
|
+
error: true,
|
|
21086
|
+
code: "FORBIDDEN",
|
|
21087
|
+
message: "leadbay_refine_prompt requires admin rights on the org",
|
|
21088
|
+
hint: "Ask your Leadbay org admin to set the refinement prompt, or use leadbay_adjust_audience for firmographic changes"
|
|
21089
|
+
};
|
|
21090
|
+
}
|
|
21091
|
+
const orgId = me.organization.id;
|
|
21092
|
+
if (params.dry_run) {
|
|
21093
|
+
return {
|
|
21094
|
+
dry_run: true,
|
|
21095
|
+
would_call: {
|
|
21096
|
+
method: "POST",
|
|
21097
|
+
path: `/organizations/${orgId}/user_prompt`,
|
|
21098
|
+
body: { user_prompt: params.prompt }
|
|
21099
|
+
}
|
|
21100
|
+
};
|
|
21101
|
+
}
|
|
21102
|
+
const postedAt = Date.now();
|
|
21103
|
+
const STALE_GUARD_MS = 5e3;
|
|
21104
|
+
await client.requestVoid("POST", `/organizations/${orgId}/user_prompt`, {
|
|
21105
|
+
user_prompt: params.prompt
|
|
21106
|
+
});
|
|
21107
|
+
client.invalidateMe();
|
|
21108
|
+
const attempts = params.clarification_poll_attempts ?? DEFAULT_POLL_ATTEMPTS;
|
|
21109
|
+
const gap = params.clarification_poll_gap_ms ?? DEFAULT_POLL_GAP_MS;
|
|
21110
|
+
let clarification = null;
|
|
21111
|
+
for (let i = 0; i < attempts; i++) {
|
|
21112
|
+
await new Promise((r) => setTimeout(r, gap));
|
|
21113
|
+
try {
|
|
21114
|
+
const c = await client.request("GET", `/organizations/${orgId}/clarifications`);
|
|
21115
|
+
if (c) {
|
|
21116
|
+
if (c.created_at) {
|
|
21117
|
+
const createdMs = Date.parse(c.created_at);
|
|
21118
|
+
if (Number.isFinite(createdMs) && createdMs < postedAt - STALE_GUARD_MS) {
|
|
21119
|
+
ctx?.logger?.warn?.(`refine_prompt: stale clarification (created_at=${c.created_at}, posted=${new Date(postedAt).toISOString()}) \u2014 ignoring`);
|
|
21120
|
+
continue;
|
|
21121
|
+
}
|
|
21122
|
+
}
|
|
21123
|
+
clarification = c;
|
|
21124
|
+
break;
|
|
21125
|
+
}
|
|
21126
|
+
} catch (err) {
|
|
21127
|
+
ctx?.logger?.warn?.(`refine_prompt: clarification poll error: ${err?.message}`);
|
|
21128
|
+
}
|
|
21129
|
+
}
|
|
21130
|
+
if (clarification) {
|
|
21131
|
+
if (ctx?.elicit) {
|
|
21132
|
+
const opts = clarification.options ?? [];
|
|
21133
|
+
const requestedSchema = opts.length > 0 ? {
|
|
21134
|
+
type: "object",
|
|
21135
|
+
properties: {
|
|
21136
|
+
option_id: {
|
|
21137
|
+
type: "string",
|
|
21138
|
+
title: "Pick one",
|
|
21139
|
+
description: "Choose the option that best matches your intent.",
|
|
21140
|
+
enum: opts.filter((o) => o.id).map((o) => o.id),
|
|
21141
|
+
enumNames: opts.filter((o) => o.id).map((o) => o.label)
|
|
21142
|
+
}
|
|
21143
|
+
},
|
|
21144
|
+
required: ["option_id"]
|
|
21145
|
+
} : {
|
|
21146
|
+
type: "object",
|
|
21147
|
+
properties: {
|
|
21148
|
+
text_answer: {
|
|
21149
|
+
type: "string",
|
|
21150
|
+
title: "Answer",
|
|
21151
|
+
description: "Free-text answer to the clarification. Plain English."
|
|
21152
|
+
}
|
|
21153
|
+
},
|
|
21154
|
+
required: ["text_answer"]
|
|
21155
|
+
};
|
|
21156
|
+
try {
|
|
21157
|
+
const elicited = await ctx.elicit({
|
|
21158
|
+
message: clarification.question,
|
|
21159
|
+
requestedSchema
|
|
21160
|
+
});
|
|
21161
|
+
if (elicited.action === "accept" && elicited.content) {
|
|
21162
|
+
const body = typeof elicited.content.option_id === "string" ? { option_id: elicited.content.option_id } : typeof elicited.content.text_answer === "string" ? { text_answer: elicited.content.text_answer } : null;
|
|
21163
|
+
if (body) {
|
|
21164
|
+
try {
|
|
21165
|
+
await client.requestVoid("POST", `/organizations/${orgId}/pick_clarification`, body);
|
|
21166
|
+
client.invalidateMe();
|
|
21167
|
+
return {
|
|
21168
|
+
status: "applied",
|
|
21169
|
+
clarified_via_elicit: true,
|
|
21170
|
+
computing_intelligence: true,
|
|
21171
|
+
message: "Prompt set + clarification answered via the client's elicitation UI. Leadbay is regenerating intelligence.",
|
|
21172
|
+
_meta: { region: client.region }
|
|
21173
|
+
};
|
|
21174
|
+
} catch (err) {
|
|
21175
|
+
ctx?.logger?.warn?.(`refine_prompt: pick_clarification POST failed after elicit: ${err?.message ?? err?.code ?? err}`);
|
|
21176
|
+
}
|
|
21177
|
+
}
|
|
21178
|
+
}
|
|
21179
|
+
} catch (err) {
|
|
21180
|
+
ctx?.logger?.warn?.(`refine_prompt: elicit failed: ${err?.message ?? err?.code ?? err} \u2014 falling back to telephone path`);
|
|
21181
|
+
}
|
|
21182
|
+
}
|
|
21183
|
+
return {
|
|
21184
|
+
status: "clarification_pending",
|
|
21185
|
+
clarification,
|
|
21186
|
+
next_action: "Call leadbay_answer_clarification with option_id (preferred) or text_answer to disambiguate",
|
|
21187
|
+
_meta: { region: client.region }
|
|
21188
|
+
};
|
|
21189
|
+
}
|
|
21190
|
+
return {
|
|
21191
|
+
status: "applied",
|
|
21192
|
+
computing_intelligence: true,
|
|
21193
|
+
message: "Prompt set. Leadbay is regenerating intelligence; new leads will reflect the refinement shortly. Check leadbay_account_status to monitor computing_intelligence.",
|
|
21194
|
+
_meta: { region: client.region }
|
|
21195
|
+
};
|
|
21196
|
+
}
|
|
21197
|
+
};
|
|
21198
|
+
}
|
|
21199
|
+
});
|
|
21200
|
+
|
|
21201
|
+
// ../core/dist/composite/seed-candidates.js
|
|
21202
|
+
var seedCandidates;
|
|
21203
|
+
var init_seed_candidates = __esm({
|
|
21204
|
+
"../core/dist/composite/seed-candidates.js"() {
|
|
21205
|
+
"use strict";
|
|
21206
|
+
init_tool_descriptions_generated();
|
|
21207
|
+
seedCandidates = {
|
|
21208
|
+
name: "leadbay_seed_candidates",
|
|
21209
|
+
annotations: {
|
|
21210
|
+
title: "List candidate seeds for a lens extra-refill",
|
|
21211
|
+
readOnlyHint: true,
|
|
21212
|
+
destructiveHint: false,
|
|
21213
|
+
idempotentHint: true,
|
|
21214
|
+
openWorldHint: true
|
|
21215
|
+
},
|
|
21216
|
+
description: leadbay_seed_candidates,
|
|
21217
|
+
inputSchema: {
|
|
21218
|
+
type: "object",
|
|
21219
|
+
properties: {
|
|
21220
|
+
lensId: {
|
|
21221
|
+
type: "number",
|
|
21222
|
+
description: "Lens to fetch candidates for. Defaults to the user's last-active lens."
|
|
21223
|
+
},
|
|
21224
|
+
limit: {
|
|
21225
|
+
type: "number",
|
|
21226
|
+
description: "Max candidates to return, 1\u201350 (backend default 20)."
|
|
21227
|
+
}
|
|
21228
|
+
},
|
|
21229
|
+
additionalProperties: false
|
|
21230
|
+
},
|
|
21231
|
+
outputSchema: {
|
|
21232
|
+
type: "object",
|
|
21233
|
+
properties: {
|
|
21234
|
+
lens: {
|
|
21235
|
+
type: "object",
|
|
21236
|
+
properties: { id: { type: "number" } }
|
|
21237
|
+
},
|
|
21238
|
+
candidates: {
|
|
21239
|
+
type: "array",
|
|
21240
|
+
description: "Ranked candidate leads \u2014 each is a valid seed for leadbay_extend_lens. Pick 3\u20135 that represent the kind of leads the user wants more of.",
|
|
21241
|
+
items: { type: "object" }
|
|
21242
|
+
}
|
|
21243
|
+
},
|
|
21244
|
+
required: ["lens", "candidates"]
|
|
21245
|
+
},
|
|
21246
|
+
execute: async (client, params) => {
|
|
21247
|
+
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
21248
|
+
const limit = params.limit != null ? Math.max(1, Math.min(params.limit, 50)) : 20;
|
|
21249
|
+
const res = await client.request("GET", `/lenses/${lensId}/seed_candidates?limit=${limit}`);
|
|
21250
|
+
return {
|
|
21251
|
+
lens: { id: lensId },
|
|
21252
|
+
candidates: res.candidates
|
|
21253
|
+
};
|
|
21254
|
+
}
|
|
21255
|
+
};
|
|
21256
|
+
}
|
|
21257
|
+
});
|
|
21258
|
+
|
|
21259
|
+
// ../core/dist/composite/_mcp-job-helpers.js
|
|
21260
|
+
import { createHash as createHash4 } from "crypto";
|
|
21261
|
+
function assertSafeJobId(jobId) {
|
|
21262
|
+
const reject = (why) => {
|
|
21263
|
+
throw {
|
|
21264
|
+
error: true,
|
|
21265
|
+
code: "INVALID_JOB_ID",
|
|
21266
|
+
message: `job_id ${why}.`,
|
|
21267
|
+
hint: "Pass the job_id exactly as leadbay_find_new_leads or leadbay_qualify_leads returned it \u2014 it is an opaque handle, not a path."
|
|
21268
|
+
};
|
|
21269
|
+
};
|
|
21270
|
+
if (typeof jobId !== "string" || jobId.length === 0)
|
|
21271
|
+
return reject("must be a non-empty string");
|
|
21272
|
+
if (jobId.length > MAX_JOB_ID_LENGTH)
|
|
21273
|
+
return reject(`is ${jobId.length} chars \u2014 the maximum is ${MAX_JOB_ID_LENGTH}`);
|
|
21274
|
+
if (/^\.+$/.test(jobId))
|
|
21275
|
+
return reject("cannot be a dot segment");
|
|
21276
|
+
if (!JOB_ID_CHARSET.test(jobId))
|
|
21277
|
+
return reject("contains characters that are not valid in a job handle");
|
|
21278
|
+
return encodeURIComponent(jobId);
|
|
21279
|
+
}
|
|
21280
|
+
async function collectJobSnapshot(client, jobId, since, limit, signal, timeoutMs = SNAPSHOT_TIMEOUT_MS) {
|
|
21281
|
+
if (signal?.aborted)
|
|
21282
|
+
throw cancelledError(jobId);
|
|
21283
|
+
const pageLimit = Math.min(Math.max(limit ?? PAGE_LIMIT, 1), PAGE_LIMIT);
|
|
21284
|
+
const safeJobId = assertSafeJobId(jobId);
|
|
21285
|
+
const qs = (cursor2) => `/mcp/jobs/${safeJobId}?limit=${pageLimit}` + (cursor2 ? `&since=${encodeURIComponent(cursor2)}` : "");
|
|
21286
|
+
const maxPages = maxPagesFor(pageLimit);
|
|
21287
|
+
const deadlineAt = Date.now() + timeoutMs;
|
|
21288
|
+
const remaining = () => deadlineAt - Date.now();
|
|
21289
|
+
let page = await client.request("GET", qs(since), void 0, {
|
|
21290
|
+
signal,
|
|
21291
|
+
// totalTimeoutMs, not timeoutMs: what is left of the wait must cover a 401
|
|
21292
|
+
// backoff and its retry too, or a blip buys the poll a second full budget.
|
|
21293
|
+
totalTimeoutMs: remaining()
|
|
21294
|
+
});
|
|
21295
|
+
const items = [...page.items];
|
|
21296
|
+
let cursor = page.next_since ?? since ?? null;
|
|
21297
|
+
let pages = 1;
|
|
21298
|
+
while (page.items.length >= pageLimit && page.next_since && pages < maxPages && !signal?.aborted) {
|
|
21299
|
+
if (remaining() <= 0)
|
|
21300
|
+
break;
|
|
21301
|
+
let next;
|
|
21302
|
+
try {
|
|
21303
|
+
next = await client.request("GET", qs(page.next_since), void 0, { signal, totalTimeoutMs: remaining() });
|
|
21304
|
+
} catch (e) {
|
|
21305
|
+
if (isTimeout(e))
|
|
21306
|
+
break;
|
|
21307
|
+
throw e;
|
|
21308
|
+
}
|
|
21309
|
+
items.push(...next.items);
|
|
21310
|
+
pages += 1;
|
|
21311
|
+
page = next;
|
|
21312
|
+
if (next.items.length === 0) {
|
|
21313
|
+
break;
|
|
21314
|
+
}
|
|
21315
|
+
cursor = next.next_since ?? cursor;
|
|
21316
|
+
}
|
|
21317
|
+
const itemsTruncated = page.items.length >= pageLimit && !!page.next_since;
|
|
21318
|
+
return {
|
|
21319
|
+
...page,
|
|
21320
|
+
items,
|
|
21321
|
+
next_since: cursor,
|
|
21322
|
+
...itemsTruncated ? { items_truncated: true } : {}
|
|
21323
|
+
};
|
|
21324
|
+
}
|
|
21325
|
+
function sleepUnlessAborted(ms, signal) {
|
|
21326
|
+
if (signal?.aborted)
|
|
21327
|
+
return Promise.resolve();
|
|
21328
|
+
return new Promise((resolve) => {
|
|
21329
|
+
const done = () => {
|
|
21330
|
+
clearTimeout(timer);
|
|
21331
|
+
signal?.removeEventListener("abort", done);
|
|
21332
|
+
resolve();
|
|
21333
|
+
};
|
|
21334
|
+
const timer = setTimeout(done, ms);
|
|
21335
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
21336
|
+
});
|
|
21337
|
+
}
|
|
21338
|
+
function cancelledError(jobId) {
|
|
21339
|
+
return {
|
|
21340
|
+
error: true,
|
|
21341
|
+
code: "REQUEST_CANCELLED",
|
|
21342
|
+
message: `The wait for job ${jobId} was cancelled before any status was read.`,
|
|
21343
|
+
hint: "The job itself is backend-owned and keeps running. Poll leadbay_lead_job_status when you want its result."
|
|
21344
|
+
};
|
|
21345
|
+
}
|
|
21346
|
+
function snapshotBudget(remainingMs) {
|
|
21347
|
+
return Math.min(SNAPSHOT_TIMEOUT_MS, Math.max(remainingMs, 1));
|
|
21348
|
+
}
|
|
21349
|
+
function isTimeout(e) {
|
|
21350
|
+
return typeof e === "object" && e !== null && e.code === "TIMEOUT";
|
|
21351
|
+
}
|
|
21352
|
+
function jobReadTimedOutError(jobId, waitSeconds) {
|
|
21353
|
+
return {
|
|
21354
|
+
error: true,
|
|
21355
|
+
code: "JOB_READ_TIMEOUT",
|
|
21356
|
+
// Structured, not just interpolated: a caller recovering programmatically
|
|
21357
|
+
// should not have to parse the message to find the handle.
|
|
21358
|
+
job_id: jobId,
|
|
21359
|
+
message: `Job ${jobId} was submitted and is running, but its status could not be read within ${waitSeconds}s.`,
|
|
21360
|
+
hint: `Pass job_id ${jobId} to leadbay_lead_job_status to read it \u2014 the job is backend-owned, still running, and its results are kept for 30 days.`
|
|
21361
|
+
};
|
|
21362
|
+
}
|
|
21363
|
+
function jobHandleError(jobId, cause) {
|
|
21364
|
+
const c = cause;
|
|
21365
|
+
if (c?.job_id === jobId)
|
|
21366
|
+
return cause;
|
|
21367
|
+
return {
|
|
21368
|
+
error: true,
|
|
21369
|
+
code: c?.code ?? "JOB_READ_FAILED",
|
|
21370
|
+
job_id: jobId,
|
|
21371
|
+
message: `Job ${jobId} was submitted and is running, but reading its status failed: ${c?.message ?? String(cause)}`,
|
|
21372
|
+
hint: `Pass job_id ${jobId} to leadbay_lead_job_status to read it \u2014 the job is backend-owned, keeps running whatever happened to this call, and its results are kept for 30 days.`
|
|
21373
|
+
};
|
|
21374
|
+
}
|
|
21375
|
+
async function snapshotAfterSubmit(client, jobId, waitSeconds, ctx, itemsRequested) {
|
|
21376
|
+
try {
|
|
21377
|
+
return waitSeconds > 0 ? await waitForJob(client, jobId, waitSeconds, ctx, itemsRequested) : await collectJobSnapshot(client, jobId, void 0, void 0, ctx?.signal);
|
|
21378
|
+
} catch (e) {
|
|
21379
|
+
throw jobHandleError(jobId, e);
|
|
21380
|
+
}
|
|
21381
|
+
}
|
|
21382
|
+
async function waitForJob(client, jobId, waitSeconds, ctx, itemsRequested, since, limit) {
|
|
21383
|
+
const startedAt = Date.now();
|
|
21384
|
+
const remainingMsOf = () => waitSeconds * 1e3 - (Date.now() - startedAt);
|
|
21385
|
+
if (ctx?.signal?.aborted)
|
|
21386
|
+
throw cancelledError(jobId);
|
|
21387
|
+
let snap;
|
|
21388
|
+
try {
|
|
21389
|
+
snap = await collectJobSnapshot(client, jobId, since, limit, ctx?.signal, snapshotBudget(remainingMsOf()));
|
|
21390
|
+
} catch (e) {
|
|
21391
|
+
if (isTimeout(e))
|
|
21392
|
+
throw jobReadTimedOutError(jobId, waitSeconds);
|
|
21393
|
+
throw e;
|
|
21394
|
+
}
|
|
21395
|
+
while (!TERMINAL_JOB_STATES.has(snap.job.state) && (Date.now() - startedAt) / 1e3 < waitSeconds && !ctx?.signal?.aborted) {
|
|
21396
|
+
const remainingMs = waitSeconds * 1e3 - (Date.now() - startedAt);
|
|
21397
|
+
if (remainingMs <= 0)
|
|
21398
|
+
break;
|
|
21399
|
+
await sleepUnlessAborted(Math.min(MCP_JOB_POLL.intervalMs, remainingMs), ctx?.signal);
|
|
21400
|
+
if (ctx?.signal?.aborted)
|
|
21401
|
+
break;
|
|
21402
|
+
if (remainingMsOf() <= 0)
|
|
21403
|
+
break;
|
|
21404
|
+
try {
|
|
21405
|
+
const fresh = await collectJobSnapshot(client, jobId, since, limit, ctx?.signal, snapshotBudget(remainingMsOf()));
|
|
21406
|
+
const regressed = fresh.items.length < snap.items.length;
|
|
21407
|
+
snap = regressed ? {
|
|
21408
|
+
...fresh,
|
|
21409
|
+
items: snap.items,
|
|
21410
|
+
next_since: snap.next_since ?? fresh.next_since,
|
|
21411
|
+
...snap.items_truncated ? { items_truncated: true } : {}
|
|
21412
|
+
} : fresh;
|
|
21413
|
+
} catch (e) {
|
|
21414
|
+
if (ctx?.signal?.aborted)
|
|
21415
|
+
break;
|
|
21416
|
+
if (isTimeout(e))
|
|
21417
|
+
break;
|
|
21418
|
+
throw e;
|
|
21419
|
+
}
|
|
21420
|
+
const f = snap.funnel;
|
|
21421
|
+
ctx?.progress?.({
|
|
21422
|
+
progress: f.delivered ?? 0,
|
|
21423
|
+
total: itemsRequested,
|
|
21424
|
+
message: `${snap.job.state}: ${f.examined ?? 0} examined, ${f.delivered ?? 0} delivered, ${snap.cost.spent}c spent`
|
|
21425
|
+
});
|
|
21426
|
+
}
|
|
21427
|
+
return snap;
|
|
21428
|
+
}
|
|
21429
|
+
function refIdentity(ref) {
|
|
21430
|
+
if (!ref || typeof ref !== "object" || Array.isArray(ref))
|
|
21431
|
+
return null;
|
|
21432
|
+
const o = ref;
|
|
21433
|
+
const str = (f) => {
|
|
21434
|
+
const v = o[f];
|
|
21435
|
+
if (typeof v !== "string")
|
|
21436
|
+
return null;
|
|
21437
|
+
const t = v.trim().toLowerCase();
|
|
21438
|
+
return t ? t : null;
|
|
21439
|
+
};
|
|
21440
|
+
const website = str("website");
|
|
21441
|
+
const parts = [
|
|
21442
|
+
normalizeUuid(o.lead_id) ?? null,
|
|
21443
|
+
normalizeUuid(o.contact_id) ?? null,
|
|
21444
|
+
website ? normalizeDomain(website) ?? website : null,
|
|
21445
|
+
str("name"),
|
|
21446
|
+
str("location")
|
|
21447
|
+
];
|
|
21448
|
+
return parts.some((p) => p !== null) ? JSON.stringify(parts) : null;
|
|
21449
|
+
}
|
|
21450
|
+
function remapInputIndexes(items, refs) {
|
|
21451
|
+
const list = Array.isArray(refs) ? refs : [];
|
|
21452
|
+
if (list.length === 0)
|
|
21453
|
+
return { items, remapped: false };
|
|
21454
|
+
const byIdentity = /* @__PURE__ */ new Map();
|
|
21455
|
+
list.forEach((ref, i) => {
|
|
21456
|
+
const key = refIdentity(ref);
|
|
21457
|
+
if (!key)
|
|
21458
|
+
return;
|
|
21459
|
+
const at = byIdentity.get(key);
|
|
21460
|
+
if (at)
|
|
21461
|
+
at.push(i);
|
|
21462
|
+
else
|
|
21463
|
+
byIdentity.set(key, [i]);
|
|
21464
|
+
});
|
|
21465
|
+
const next = [];
|
|
21466
|
+
let ok = true;
|
|
21467
|
+
for (const item of items) {
|
|
21468
|
+
const ref = item.ref;
|
|
21469
|
+
if (!ref || ref.input_indexes == null) {
|
|
21470
|
+
next.push(item);
|
|
21471
|
+
continue;
|
|
21472
|
+
}
|
|
21473
|
+
const key = refIdentity(ref.requested_as) ?? refIdentity({ lead_id: ref.lead_id ?? void 0 });
|
|
21474
|
+
const found = key ? byIdentity.get(key) : void 0;
|
|
21475
|
+
if (!found) {
|
|
21476
|
+
ok = false;
|
|
21477
|
+
break;
|
|
21478
|
+
}
|
|
21479
|
+
next.push({ ...item, ref: { ...ref, input_indexes: found } });
|
|
21480
|
+
}
|
|
21481
|
+
if (!ok) {
|
|
21482
|
+
return {
|
|
21483
|
+
items: items.map((item) => item.ref && item.ref.input_indexes != null ? { ...item, ref: { ...item.ref, input_indexes: null } } : item),
|
|
21484
|
+
remapped: false
|
|
21485
|
+
};
|
|
21486
|
+
}
|
|
21487
|
+
return { items: next, remapped: true };
|
|
21488
|
+
}
|
|
21489
|
+
function canonicalSet(values) {
|
|
21490
|
+
const list = values === void 0 || values === null ? [] : Array.isArray(values) ? values : [values];
|
|
21491
|
+
return [...new Set(list.map((v) => JSON.stringify(v)))].sort().map((v) => JSON.parse(v));
|
|
21492
|
+
}
|
|
21493
|
+
function coerceArrayParams(params, keys) {
|
|
21494
|
+
const out = { ...params };
|
|
21495
|
+
for (const key of keys) {
|
|
21496
|
+
const v = out[key];
|
|
21497
|
+
if (v !== void 0 && v !== null && !Array.isArray(v)) {
|
|
21498
|
+
out[key] = [v];
|
|
21499
|
+
}
|
|
21500
|
+
}
|
|
21501
|
+
return out;
|
|
21502
|
+
}
|
|
21503
|
+
function presentRequestId(value) {
|
|
21504
|
+
if (typeof value !== "string")
|
|
21505
|
+
return void 0;
|
|
21506
|
+
const trimmed = value.trim();
|
|
21507
|
+
return trimmed ? trimmed : void 0;
|
|
21508
|
+
}
|
|
21509
|
+
function isUuidShaped(value) {
|
|
21510
|
+
return typeof value === "string" && UUID_RE2.test(value.trim());
|
|
21511
|
+
}
|
|
21512
|
+
function normalizeUuid(value) {
|
|
21513
|
+
if (typeof value !== "string")
|
|
21514
|
+
return null;
|
|
21515
|
+
const v = value.trim();
|
|
21516
|
+
if (!v)
|
|
21517
|
+
return null;
|
|
21518
|
+
return UUID_RE2.test(v) ? v.toLowerCase() : v;
|
|
21519
|
+
}
|
|
21520
|
+
function canonicalIdSet(values) {
|
|
21521
|
+
const list = values === void 0 || values === null ? [] : Array.isArray(values) ? values : [values];
|
|
21522
|
+
return canonicalSet(list.map(normalizeUuid).filter((v) => !!v));
|
|
21523
|
+
}
|
|
21524
|
+
function canonicalLabelSet(values) {
|
|
21525
|
+
const list = values === void 0 || values === null ? [] : Array.isArray(values) ? values : [values];
|
|
21526
|
+
return canonicalSet(list.filter((v) => typeof v === "string").map((v) => v.trim().toLowerCase()).filter(Boolean));
|
|
21527
|
+
}
|
|
21528
|
+
function canonicalOptionalObject(value) {
|
|
21529
|
+
if (!value)
|
|
21530
|
+
return null;
|
|
21531
|
+
const out = {};
|
|
21532
|
+
for (const [k, v] of Object.entries(value)) {
|
|
21533
|
+
if (v === void 0 || v === null)
|
|
21534
|
+
continue;
|
|
21535
|
+
if (Array.isArray(v) && v.length === 0)
|
|
21536
|
+
continue;
|
|
21537
|
+
out[k] = v;
|
|
21538
|
+
}
|
|
21539
|
+
return Object.keys(out).length === 0 ? null : out;
|
|
21540
|
+
}
|
|
21541
|
+
function canonicalize(value) {
|
|
21542
|
+
if (Array.isArray(value))
|
|
21543
|
+
return value.map(canonicalize);
|
|
21544
|
+
if (value && typeof value === "object") {
|
|
21545
|
+
const out = {};
|
|
21546
|
+
for (const k of Object.keys(value).sort()) {
|
|
21547
|
+
out[k] = canonicalize(value[k]);
|
|
21548
|
+
}
|
|
21549
|
+
return out;
|
|
21550
|
+
}
|
|
21551
|
+
return value;
|
|
21552
|
+
}
|
|
21553
|
+
function derivedKey(prefix, shape) {
|
|
21554
|
+
const serialized = typeof shape === "string" ? shape : JSON.stringify(canonicalize(shape));
|
|
21555
|
+
return `${prefix}-${createHash4("sha256").update(serialized).digest("hex").slice(0, 32)}`;
|
|
21556
|
+
}
|
|
21557
|
+
function mockedSubmitPreview(submit, tool, region) {
|
|
21558
|
+
const s = submit ?? {};
|
|
21559
|
+
if (typeof s.job_id === "string" && s.job_id)
|
|
21560
|
+
return null;
|
|
21561
|
+
if (process.env.LEADBAY_MOCK !== "1") {
|
|
21562
|
+
throw {
|
|
21563
|
+
error: true,
|
|
21564
|
+
code: "MALFORMED_SUBMIT_RESPONSE",
|
|
21565
|
+
message: `${tool}: the submit succeeded but the response carried no job_id, so the job cannot be polled.`,
|
|
21566
|
+
hint: "The job may still be running server-side. Do not re-submit blindly \u2014 reuse the same request_id so a retry dedupes instead of double-spending."
|
|
21567
|
+
};
|
|
21568
|
+
}
|
|
21569
|
+
return {
|
|
21570
|
+
mocked: true,
|
|
21571
|
+
tool,
|
|
21572
|
+
submitted: false,
|
|
21573
|
+
would_call: s.would_call ?? null,
|
|
21574
|
+
note: "LEADBAY_MOCK=1 \u2014 the job was not submitted, so there is no job to poll.",
|
|
21575
|
+
region
|
|
21576
|
+
};
|
|
21577
|
+
}
|
|
21578
|
+
function splitItems(snapshot) {
|
|
21579
|
+
const leads = [];
|
|
21580
|
+
const skipped = [];
|
|
21581
|
+
for (const item of snapshot.items) {
|
|
21582
|
+
if (item.status === "skipped")
|
|
21583
|
+
skipped.push(item);
|
|
21584
|
+
else
|
|
21585
|
+
leads.push(item);
|
|
21586
|
+
}
|
|
21587
|
+
return { leads, skipped };
|
|
21588
|
+
}
|
|
21589
|
+
function compactBody(body) {
|
|
21590
|
+
return Object.fromEntries(Object.entries(body).filter(([, v]) => v !== void 0));
|
|
21591
|
+
}
|
|
21592
|
+
function exemptionsFor(region) {
|
|
21593
|
+
const key = typeof region === "string" ? region.trim().toLowerCase() : "";
|
|
21594
|
+
return SUBNATIONAL_EXEMPTIONS[key] ?? ALL_EXEMPTIONS;
|
|
21595
|
+
}
|
|
21596
|
+
function buildCountryLocationValues() {
|
|
21597
|
+
const values = new Set(COUNTRY_ALIASES.map(countryKey2));
|
|
21598
|
+
try {
|
|
21599
|
+
const A = "A".charCodeAt(0);
|
|
21600
|
+
const displays = ["en", "fr"].map((locale) => new Intl.DisplayNames([locale], { type: "region", fallback: "none" }));
|
|
21601
|
+
for (let i = 0; i < 26; i++) {
|
|
21602
|
+
for (let j = 0; j < 26; j++) {
|
|
21603
|
+
const code = String.fromCharCode(A + i) + String.fromCharCode(A + j);
|
|
21604
|
+
for (const display of displays) {
|
|
21605
|
+
const name = display.of(code);
|
|
21606
|
+
if (!name || name === code)
|
|
21607
|
+
continue;
|
|
21608
|
+
values.add(countryKey2(name));
|
|
21609
|
+
}
|
|
21610
|
+
}
|
|
21611
|
+
}
|
|
21612
|
+
} catch {
|
|
21613
|
+
}
|
|
21614
|
+
return values;
|
|
21615
|
+
}
|
|
21616
|
+
function countryKey2(raw) {
|
|
21617
|
+
return raw.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[-_,]/g, " ").replace(/^\s*(l|d)['’]\s*/, "").replace(/['’.]/g, "").replace(/\s+/g, " ").trim().replace(/^(les|the|la|le|l)\s+/, "").trim();
|
|
21618
|
+
}
|
|
21619
|
+
function rejectMalformedExclusions(ids) {
|
|
21620
|
+
if (ids === void 0 || ids === null)
|
|
21621
|
+
return;
|
|
21622
|
+
const list = Array.isArray(ids) ? ids : [ids];
|
|
21623
|
+
const bad = [];
|
|
21624
|
+
list.forEach((v, i) => {
|
|
21625
|
+
if (typeof v !== "string") {
|
|
21626
|
+
bad.push(`${i} (${v === null ? "null" : typeof v})`);
|
|
21627
|
+
} else if (!v.trim()) {
|
|
21628
|
+
bad.push(`${i} (blank)`);
|
|
21629
|
+
}
|
|
21630
|
+
});
|
|
21631
|
+
if (bad.length === 0)
|
|
21632
|
+
return;
|
|
21633
|
+
throw {
|
|
21634
|
+
error: true,
|
|
21635
|
+
code: "INVALID_EXCLUDE_LEAD_ID",
|
|
21636
|
+
message: `exclude_lead_ids has ${bad.length} entr${bad.length === 1 ? "y" : "ies"} that is not a lead id: ${bad.join(", ")}.`,
|
|
21637
|
+
hint: "Drop or fix those entries and re-call \u2014 every entry must be a non-blank lead id string. Silently skipping them would run the search without an exclusion you asked for, and could re-deliver and charge for that exact lead."
|
|
21638
|
+
};
|
|
21639
|
+
}
|
|
21640
|
+
function rejectOversizedExclusions(ids) {
|
|
21641
|
+
if (ids === void 0 || ids === null)
|
|
21642
|
+
return;
|
|
21643
|
+
const unique = canonicalIdSet(ids);
|
|
21644
|
+
if (unique.length <= MAX_EXCLUDE_LEAD_IDS)
|
|
21645
|
+
return;
|
|
21646
|
+
throw {
|
|
21647
|
+
error: true,
|
|
21648
|
+
code: "TOO_MANY_EXCLUSIONS",
|
|
21649
|
+
message: `exclude_lead_ids carries ${unique.length} ids \u2014 the maximum is ${MAX_EXCLUDE_LEAD_IDS}.`,
|
|
21650
|
+
hint: "Drop the DELIVERED ids first: novelty:'org' already excludes those. Send the examined-but-rejected ones (disqualified + skipped), most recent first, capped at 500."
|
|
21651
|
+
};
|
|
21652
|
+
}
|
|
21653
|
+
function rejectOversizedLeadRefs(refs) {
|
|
21654
|
+
if (refs === void 0 || refs === null)
|
|
21655
|
+
return;
|
|
21656
|
+
const list = Array.isArray(refs) ? refs : [refs];
|
|
21657
|
+
const unique = /* @__PURE__ */ new Set();
|
|
21658
|
+
let unkeyed = 0;
|
|
21659
|
+
for (const ref of list) {
|
|
21660
|
+
const key = refIdentity(ref);
|
|
21661
|
+
if (key === null)
|
|
21662
|
+
unkeyed += 1;
|
|
21663
|
+
else
|
|
21664
|
+
unique.add(key);
|
|
21665
|
+
}
|
|
21666
|
+
const count = unique.size + unkeyed;
|
|
21667
|
+
if (count <= MAX_LEAD_REFS)
|
|
21668
|
+
return;
|
|
21669
|
+
throw {
|
|
21670
|
+
error: true,
|
|
21671
|
+
code: "TOO_MANY_LEAD_REFS",
|
|
21672
|
+
message: `lead_refs carries ${count} companies \u2014 the maximum is ${MAX_LEAD_REFS}.`,
|
|
21673
|
+
hint: `Split the batch into runs of ${MAX_LEAD_REFS} or fewer and call leadbay_qualify_leads once per run, each with its OWN request_id. Results accumulate in the org ledger, so a later run can re-read the earlier ones via prior_deliveries.`
|
|
21674
|
+
};
|
|
21675
|
+
}
|
|
21676
|
+
function readSpendFlag(value, field) {
|
|
21677
|
+
if (value === void 0 || value === null)
|
|
21678
|
+
return void 0;
|
|
21679
|
+
if (typeof value === "boolean")
|
|
21680
|
+
return value;
|
|
21681
|
+
if (typeof value === "string") {
|
|
21682
|
+
const t = value.trim().toLowerCase();
|
|
21683
|
+
if (t === "true")
|
|
21684
|
+
return true;
|
|
21685
|
+
if (t === "false")
|
|
21686
|
+
return false;
|
|
21687
|
+
}
|
|
21688
|
+
throw {
|
|
21689
|
+
error: true,
|
|
21690
|
+
code: "BAD_INPUT",
|
|
21691
|
+
message: `${field} must be a boolean (got ${Array.isArray(value) ? "array" : typeof value}: ${JSON.stringify(value)}).`,
|
|
21692
|
+
hint: `Re-call the tool with ${field}: true or ${field}: false as a JSON boolean, not a string or a number. This flag decides whether the user is charged, so an unrecognised value is refused rather than guessed.`
|
|
21693
|
+
};
|
|
21694
|
+
}
|
|
21695
|
+
function rejectCountryLocations(locations, region) {
|
|
21696
|
+
if (locations === void 0 || locations === null)
|
|
21697
|
+
return;
|
|
21698
|
+
const exempt = exemptionsFor(region);
|
|
21699
|
+
const list = Array.isArray(locations) ? locations : [locations];
|
|
21700
|
+
for (const loc of list) {
|
|
21701
|
+
if (typeof loc !== "string")
|
|
21702
|
+
continue;
|
|
21703
|
+
const key = countryKey2(loc);
|
|
21704
|
+
if (!exempt.has(key) && COUNTRY_LOCATION_VALUES.has(key)) {
|
|
21705
|
+
throw {
|
|
21706
|
+
error: true,
|
|
21707
|
+
code: "COUNTRY_LEVEL_LOCATION",
|
|
21708
|
+
message: `filters.locations value "${loc}" is country-level \u2014 it would silently fence the search to a same-named town, not the whole country.`,
|
|
21709
|
+
hint: 'Whole-country intent = OMIT filters.locations entirely (each universe is single-country). Use city/state/region names for narrower fences. If you meant a town that shares the name, qualify it with its state or region (e.g. "Lebanon, Kentucky").'
|
|
21710
|
+
};
|
|
21711
|
+
}
|
|
21712
|
+
}
|
|
21713
|
+
}
|
|
21714
|
+
function normalizeSearchFilters(filters) {
|
|
21715
|
+
if (filters == null)
|
|
21716
|
+
return void 0;
|
|
21717
|
+
const { employees, employeesMin, employeesMax, ...rest } = filters;
|
|
21718
|
+
const out = { ...rest };
|
|
21719
|
+
if (out.employees_min == null) {
|
|
21720
|
+
out.employees_min = employees?.min ?? employees?.employees_min ?? employeesMin;
|
|
21721
|
+
}
|
|
21722
|
+
if (out.employees_max == null) {
|
|
21723
|
+
out.employees_max = employees?.max ?? employees?.employees_max ?? employeesMax;
|
|
21724
|
+
}
|
|
21725
|
+
if (out.employees_min == null)
|
|
21726
|
+
delete out.employees_min;
|
|
21727
|
+
if (out.employees_max == null)
|
|
21728
|
+
delete out.employees_max;
|
|
21729
|
+
for (const key of ["sectors", "locations"]) {
|
|
21730
|
+
const v = out[key];
|
|
21731
|
+
if (typeof v === "string")
|
|
21732
|
+
out[key] = v.trim() ? [v] : void 0;
|
|
21733
|
+
if (out[key] === void 0)
|
|
21734
|
+
delete out[key];
|
|
21735
|
+
}
|
|
21736
|
+
return out;
|
|
21737
|
+
}
|
|
21738
|
+
function clampWaitSeconds(requested, fallback) {
|
|
21739
|
+
if (requested == null || Number.isNaN(requested))
|
|
21740
|
+
return fallback;
|
|
21741
|
+
return Math.min(Math.max(requested, 0), 180);
|
|
21742
|
+
}
|
|
21743
|
+
var TERMINAL_JOB_STATES, MCP_JOB_POLL, SNAPSHOT_TIMEOUT_MS, PAGE_LIMIT, MAX_JOB_ITEMS, MIN_PAGES, maxPagesFor, JOB_ID_CHARSET, MAX_JOB_ID_LENGTH, UUID_RE2, COUNTRY_ALIASES, SUBNATIONAL_EXEMPTIONS, ALL_EXEMPTIONS, COUNTRY_LOCATION_VALUES, MAX_EXCLUDE_LEAD_IDS, MAX_LEAD_REFS;
|
|
21744
|
+
var init_mcp_job_helpers = __esm({
|
|
21745
|
+
"../core/dist/composite/_mcp-job-helpers.js"() {
|
|
21746
|
+
"use strict";
|
|
21747
|
+
init_import_leads();
|
|
21748
|
+
TERMINAL_JOB_STATES = /* @__PURE__ */ new Set([
|
|
21749
|
+
"completed",
|
|
21750
|
+
"completed_partial",
|
|
21751
|
+
"failed",
|
|
21752
|
+
"expired"
|
|
21753
|
+
]);
|
|
21754
|
+
MCP_JOB_POLL = { intervalMs: 4e3 };
|
|
21755
|
+
SNAPSHOT_TIMEOUT_MS = 3e4;
|
|
21756
|
+
PAGE_LIMIT = 100;
|
|
21757
|
+
MAX_JOB_ITEMS = 1e3;
|
|
21758
|
+
MIN_PAGES = 20;
|
|
21759
|
+
maxPagesFor = (pageLimit) => Math.max(MIN_PAGES, Math.ceil(MAX_JOB_ITEMS / pageLimit) + 1);
|
|
21760
|
+
JOB_ID_CHARSET = /^[A-Za-z0-9._~-]+$/;
|
|
21761
|
+
MAX_JOB_ID_LENGTH = 200;
|
|
21762
|
+
UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21763
|
+
COUNTRY_ALIASES = [
|
|
21764
|
+
"united states",
|
|
21765
|
+
"united states of america",
|
|
21766
|
+
"usa",
|
|
21767
|
+
"us",
|
|
21768
|
+
"america",
|
|
21769
|
+
"etats unis",
|
|
21770
|
+
"etats unis d amerique",
|
|
21771
|
+
"france",
|
|
21772
|
+
"fr",
|
|
21773
|
+
"french republic",
|
|
21774
|
+
"republique francaise"
|
|
21775
|
+
];
|
|
21776
|
+
SUBNATIONAL_EXEMPTIONS = {
|
|
21777
|
+
us: new Set(["georgia", "georgie"].map(countryKey2)),
|
|
21778
|
+
fr: new Set([
|
|
21779
|
+
"guadeloupe",
|
|
21780
|
+
"martinique",
|
|
21781
|
+
"reunion",
|
|
21782
|
+
"mayotte",
|
|
21783
|
+
"french guiana",
|
|
21784
|
+
"guyane francaise",
|
|
21785
|
+
"new caledonia",
|
|
21786
|
+
"nouvelle caledonie",
|
|
21787
|
+
"french polynesia",
|
|
21788
|
+
"polynesie francaise",
|
|
21789
|
+
"saint martin",
|
|
21790
|
+
"saint barthelemy",
|
|
21791
|
+
"saint pierre and miquelon",
|
|
21792
|
+
"saint pierre et miquelon",
|
|
21793
|
+
"wallis and futuna",
|
|
21794
|
+
"wallis et futuna"
|
|
21795
|
+
].map(countryKey2))
|
|
21796
|
+
};
|
|
21797
|
+
ALL_EXEMPTIONS = new Set(Object.values(SUBNATIONAL_EXEMPTIONS).flatMap((s) => [...s]));
|
|
21798
|
+
COUNTRY_LOCATION_VALUES = buildCountryLocationValues();
|
|
21799
|
+
MAX_EXCLUDE_LEAD_IDS = 500;
|
|
21800
|
+
MAX_LEAD_REFS = 500;
|
|
21801
|
+
}
|
|
21802
|
+
});
|
|
21803
|
+
|
|
21804
|
+
// ../core/dist/composite/find-new-leads.js
|
|
21805
|
+
function sortFilterLists(filters) {
|
|
21806
|
+
if (!filters)
|
|
21807
|
+
return null;
|
|
21808
|
+
const out = { ...filters };
|
|
21809
|
+
for (const key of ["sectors", "locations"]) {
|
|
21810
|
+
if (Array.isArray(out[key])) {
|
|
21811
|
+
out[key] = canonicalLabelSet(out[key]);
|
|
21812
|
+
}
|
|
21813
|
+
}
|
|
21814
|
+
return out;
|
|
21815
|
+
}
|
|
21816
|
+
var DEFAULT_WAIT_SECONDS, findNewLeads;
|
|
21817
|
+
var init_find_new_leads = __esm({
|
|
21818
|
+
"../core/dist/composite/find-new-leads.js"() {
|
|
21819
|
+
"use strict";
|
|
21820
|
+
init_mcp_job_helpers();
|
|
21821
|
+
init_tool_descriptions_generated();
|
|
21822
|
+
DEFAULT_WAIT_SECONDS = 45;
|
|
21823
|
+
findNewLeads = {
|
|
21824
|
+
name: "leadbay_find_new_leads",
|
|
21825
|
+
annotations: {
|
|
21826
|
+
title: "Find new leads (net-new ICP search)",
|
|
21827
|
+
readOnlyHint: false,
|
|
21828
|
+
// The tool CAN bill (qualify:true and/or channels) and records deliveries
|
|
21829
|
+
// in the org novelty ledger, so it advertises destructive like the other
|
|
21830
|
+
// paid composites — annotations are static and must describe the worst
|
|
21831
|
+
// case, not the default. The free path is protected in execute() instead:
|
|
21832
|
+
// a paid call is withheld until `confirm: true`.
|
|
21833
|
+
destructiveHint: true,
|
|
21834
|
+
// The mandatory request_id dedups: re-submitting the same request returns
|
|
21835
|
+
// the SAME live job instead of double-spending.
|
|
21836
|
+
idempotentHint: true,
|
|
21837
|
+
openWorldHint: true
|
|
21838
|
+
},
|
|
21839
|
+
write: true,
|
|
21840
|
+
description: leadbay_find_new_leads,
|
|
21841
|
+
inputSchema: {
|
|
21842
|
+
type: "object",
|
|
21843
|
+
properties: {
|
|
21844
|
+
query: {
|
|
21845
|
+
type: "string",
|
|
21846
|
+
description: "Natural-language ICP ask. Matches topic VOCABULARY \u2014 can surface vendors of a product as easily as buyers of it. Prefer example_lead; use query only when the user's wording carries signal an example can't."
|
|
21847
|
+
},
|
|
21848
|
+
example_lead: {
|
|
21849
|
+
type: "object",
|
|
21850
|
+
description: "A FICTIONAL typical ideal customer used as a look-alike seed \u2014 the highest-leverage input. Put everything in `description` (registry 'About Us' style, what the company IS); leave `name` unset (a distinctive invented name pulls matches toward name-lookalikes).",
|
|
21851
|
+
properties: {
|
|
21852
|
+
name: { type: "string" },
|
|
21853
|
+
description: { type: "string" },
|
|
21854
|
+
location: { type: "string" },
|
|
21855
|
+
employees: { type: "number" }
|
|
21856
|
+
},
|
|
21857
|
+
additionalProperties: false
|
|
21858
|
+
},
|
|
21859
|
+
filters: {
|
|
21860
|
+
type: "object",
|
|
21861
|
+
description: "HARD constraints (the seed only shapes ranking). Sector/location labels resolve at submit; an unresolvable value is a 400 naming it.",
|
|
21862
|
+
properties: {
|
|
21863
|
+
sectors: { type: "array", items: { type: "string" } },
|
|
21864
|
+
locations: { type: "array", items: { type: "string" } },
|
|
21865
|
+
employees_min: { type: "number" },
|
|
21866
|
+
employees_max: { type: "number" }
|
|
21867
|
+
},
|
|
21868
|
+
additionalProperties: false
|
|
21869
|
+
},
|
|
21870
|
+
count: {
|
|
21871
|
+
type: "number",
|
|
21872
|
+
description: "Target DELIVERED leads, 1-50. With qualify:true this means n SURVIVORS of qualification, not n candidates examined."
|
|
21873
|
+
},
|
|
21874
|
+
qualify: {
|
|
21875
|
+
type: "boolean",
|
|
21876
|
+
description: "Run fresh AI qualification and drop candidates scoring below min_ai_score. PAID: ~94 cost_cents per candidate EXAMINED (survivor or not). Default false (free)."
|
|
21877
|
+
},
|
|
21878
|
+
min_ai_score: {
|
|
21879
|
+
type: "number",
|
|
21880
|
+
description: "Disqualification floor on the [-30,+30] qualification DELTA (not the 0-100 fit score). Default 0. Lower to -30 to keep every evaluated lead with its evidence."
|
|
21881
|
+
},
|
|
21882
|
+
contact_titles: {
|
|
21883
|
+
type: "array",
|
|
21884
|
+
items: { type: "string" },
|
|
21885
|
+
description: "Wanted decision-maker titles (max 10), matched semantically cross-language."
|
|
21886
|
+
},
|
|
21887
|
+
title_gate: {
|
|
21888
|
+
type: "string",
|
|
21889
|
+
enum: ["strict", "prefer"],
|
|
21890
|
+
description: "strict = only leads with a matching known contact; prefer (default when contact_titles set) = matched first, rest flagged."
|
|
21891
|
+
},
|
|
21892
|
+
channels: {
|
|
21893
|
+
type: "array",
|
|
21894
|
+
items: { type: "string", enum: ["email", "phone"] },
|
|
21895
|
+
description: "Contact channels to PURCHASE (email 25c, phone 250c, billed on success only). Empty = free identity tier."
|
|
21896
|
+
},
|
|
21897
|
+
exclude_lead_ids: {
|
|
21898
|
+
type: "array",
|
|
21899
|
+
items: { type: "string" },
|
|
21900
|
+
description: "Caller-side novelty belt on top of the server-side one (max 500 ids \u2014 over that the call is refused, so drop the DELIVERED ids first: novelty:'org' already covers those, and the examined-but-rejected ones are what it misses)."
|
|
21901
|
+
},
|
|
21902
|
+
novelty: {
|
|
21903
|
+
type: "string",
|
|
21904
|
+
enum: ["org", "none"],
|
|
21905
|
+
description: "org (default) = only companies NEW to the org (excludes org leads, lens members, CRM ids, prior MCP deliveries)."
|
|
21906
|
+
},
|
|
21907
|
+
max_cost: {
|
|
21908
|
+
type: "number",
|
|
21909
|
+
description: "Spend cap for the whole job in cost_cents. Defaults by plan tier (500/2000/5000). The job stops honestly at the cap (stop_reason max_cost)."
|
|
21910
|
+
},
|
|
21911
|
+
exploration_cap: {
|
|
21912
|
+
type: "number",
|
|
21913
|
+
description: "Max candidates the qualify gate may examine. Default min(3n,150), ceiling min(20n,1000)."
|
|
21914
|
+
},
|
|
21915
|
+
request_id: {
|
|
21916
|
+
type: "string",
|
|
21917
|
+
description: "REQUIRED idempotency key. Derive it from the ask (e.g. 'gyms-texas-2026-07-28'); REUSE the exact same value when retrying the same ask \u2014 a duplicate returns the SAME job instead of double-spending. Use a NEW value only for a genuinely new ask."
|
|
21918
|
+
},
|
|
21919
|
+
lang: { type: "string", description: "Output language (default: user's language)." },
|
|
21920
|
+
confirm: {
|
|
21921
|
+
type: "boolean",
|
|
21922
|
+
description: "Explicit spend decision, required only for a PAID search (qualify:true and/or channels). true = the user approved the quote, go ahead. false = a veto (returns mode:'needs_confirmation', spends nothing). Omitted on a paid call \u2192 the tool withholds the submit and returns a free quote to show the user first. The default FREE search (no qualify, no channels) needs no confirm."
|
|
21923
|
+
},
|
|
21924
|
+
dry_run: {
|
|
21925
|
+
type: "boolean",
|
|
21926
|
+
description: "Validate + worst-case cost estimate + quota forecast. No job, no spend. Use before the first PAID run of a session."
|
|
21927
|
+
},
|
|
21928
|
+
wait_seconds: {
|
|
21929
|
+
type: "number",
|
|
21930
|
+
description: "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Free searches usually finish inside the window; paid exploration can take minutes \u2014 the result then carries still_running:true and the job_id to check with leadbay_lead_job_status."
|
|
21931
|
+
}
|
|
21932
|
+
},
|
|
21933
|
+
required: ["count", "request_id"],
|
|
21934
|
+
additionalProperties: false
|
|
21935
|
+
},
|
|
21936
|
+
execute: async (client, params, ctx) => {
|
|
21937
|
+
params = coerceArrayParams(params, [
|
|
21938
|
+
"contact_titles",
|
|
21939
|
+
"channels",
|
|
21940
|
+
"exclude_lead_ids"
|
|
21941
|
+
]);
|
|
21942
|
+
rejectCountryLocations(params.filters?.locations, client.region);
|
|
21943
|
+
rejectMalformedExclusions(params.exclude_lead_ids);
|
|
21944
|
+
rejectOversizedExclusions(params.exclude_lead_ids);
|
|
21945
|
+
const qualify = readSpendFlag(params.qualify, "qualify");
|
|
21946
|
+
const dryRun = readSpendFlag(params.dry_run, "dry_run");
|
|
21947
|
+
const confirm = readSpendFlag(params.confirm, "confirm");
|
|
21948
|
+
params = { ...params, qualify, dry_run: dryRun, confirm };
|
|
21949
|
+
const buysChannels = (params.channels?.length ?? 0) > 0;
|
|
21950
|
+
const buysQualification = qualify === true;
|
|
21951
|
+
const isPaid = buysQualification || buysChannels;
|
|
21952
|
+
const vetoed = confirm === false;
|
|
21953
|
+
const consented = !vetoed && confirm === true;
|
|
21954
|
+
const requestId = presentRequestId(params.request_id) ?? derivedKey(
|
|
21955
|
+
"search-auto",
|
|
21956
|
+
// Passed as an OBJECT: derivedKey canonicalizes recursively, so nested
|
|
21957
|
+
// property order (example_lead, filters) can never fork the key. Fields
|
|
21958
|
+
// with a documented backend default are canonicalized TO that default,
|
|
21959
|
+
// so an approval that omits one and a retry that passes it explicitly
|
|
21960
|
+
// derive the same key rather than launching a second paid,
|
|
21961
|
+
// novelty-claiming job.
|
|
21962
|
+
{
|
|
21963
|
+
query: params.query ?? null,
|
|
21964
|
+
example_lead: params.example_lead ?? null,
|
|
21965
|
+
// Sector/location lists are unordered sets to the backend — sort
|
|
21966
|
+
// them so a reordered retry still dedupes.
|
|
21967
|
+
filters: canonicalOptionalObject(sortFilterLists(normalizeSearchFilters(params.filters))),
|
|
21968
|
+
count: params.count ?? null,
|
|
21969
|
+
qualify: params.qualify === true,
|
|
21970
|
+
min_ai_score: params.min_ai_score ?? 0,
|
|
21971
|
+
contact_titles: canonicalLabelSet(params.contact_titles),
|
|
21972
|
+
title_gate: params.title_gate ?? ((params.contact_titles?.length ?? 0) > 0 ? "prefer" : null),
|
|
21973
|
+
channels: canonicalSet(params.channels),
|
|
21974
|
+
// Sorted so ordering alone never forks the key, but PRESENT — a
|
|
21975
|
+
// top-up differing only by exclude_lead_ids is a different approved
|
|
21976
|
+
// search, and hashing it the same would return the first job as a
|
|
21977
|
+
// duplicate with the exclusions never applied.
|
|
21978
|
+
exclude_lead_ids: canonicalIdSet(params.exclude_lead_ids),
|
|
21979
|
+
novelty: params.novelty ?? "org",
|
|
21980
|
+
max_cost: params.max_cost ?? null,
|
|
21981
|
+
// Documented backend default is min(3n,150), so an omitted cap is
|
|
21982
|
+
// canonicalized TO it — same principle as min_ai_score/novelty above.
|
|
21983
|
+
// Otherwise an approval that omits the cap and a retry that passes the
|
|
21984
|
+
// materialized default ask for identical work under different keys,
|
|
21985
|
+
// and the retry escapes dedupe into a second paid, novelty-claiming
|
|
21986
|
+
// job. An explicit non-default cap still hashes distinctly.
|
|
21987
|
+
exploration_cap: params.exploration_cap ?? (typeof params.count === "number" && params.count > 0 ? Math.min(3 * params.count, 150) : null),
|
|
21988
|
+
lang: params.lang ?? null
|
|
21989
|
+
}
|
|
21990
|
+
);
|
|
21991
|
+
const body = compactBody({
|
|
21992
|
+
query: params.query,
|
|
21993
|
+
example_lead: params.example_lead,
|
|
21994
|
+
filters: normalizeSearchFilters(params.filters),
|
|
21995
|
+
count: params.count,
|
|
21996
|
+
qualify,
|
|
21997
|
+
min_ai_score: params.min_ai_score,
|
|
21998
|
+
contact_titles: params.contact_titles,
|
|
21999
|
+
title_gate: params.title_gate,
|
|
22000
|
+
channels: params.channels,
|
|
22001
|
+
// Wire the SAME list the cap guard counted and the idempotency key was
|
|
22002
|
+
// derived from. Posting the raw array instead let a 600-entry list that
|
|
22003
|
+
// dedupes to 400 clear the guard and still be refused by the backend.
|
|
22004
|
+
// Kept undefined when absent so compactBody drops it rather than
|
|
22005
|
+
// sending an empty array.
|
|
22006
|
+
exclude_lead_ids: params.exclude_lead_ids ? canonicalIdSet(params.exclude_lead_ids) : void 0,
|
|
22007
|
+
novelty: params.novelty,
|
|
22008
|
+
max_cost: params.max_cost,
|
|
22009
|
+
exploration_cap: params.exploration_cap,
|
|
22010
|
+
request_id: requestId,
|
|
22011
|
+
lang: params.lang,
|
|
22012
|
+
dry_run: dryRun
|
|
22013
|
+
});
|
|
22014
|
+
if (dryRun === true) {
|
|
22015
|
+
const forecast = await client.request("POST", "/mcp/search", body);
|
|
22016
|
+
return {
|
|
22017
|
+
dry_run: true,
|
|
22018
|
+
...forecast,
|
|
22019
|
+
region: client.region
|
|
22020
|
+
};
|
|
22021
|
+
}
|
|
22022
|
+
if (isPaid && !consented) {
|
|
22023
|
+
const forecast = vetoed ? null : await client.request("POST", "/mcp/search", {
|
|
22024
|
+
...body,
|
|
22025
|
+
dry_run: true
|
|
22026
|
+
});
|
|
22027
|
+
return {
|
|
22028
|
+
mode: "needs_confirmation",
|
|
22029
|
+
submitted: false,
|
|
22030
|
+
vetoed,
|
|
22031
|
+
paid_because: [
|
|
22032
|
+
buysQualification ? "qualify: true (~94 cost_cents per candidate EXAMINED)" : null,
|
|
22033
|
+
buysChannels ? `channels requested: ${params.channels.join(", ")}` : null
|
|
22034
|
+
].filter(Boolean),
|
|
22035
|
+
quote: forecast,
|
|
22036
|
+
estimated_cost: forecast?.estimated_cost ?? null,
|
|
22037
|
+
items_requested: forecast?.items_requested ?? null,
|
|
22038
|
+
hint: vetoed ? "confirm:false vetoed the spend \u2014 nothing was submitted. Re-call with confirm:true to proceed, or drop qualify/channels for a free search." : "Show the user this worst-case quote and get an explicit go-ahead, then re-call with confirm:true. For a free search instead: omit qualify and channels.",
|
|
22039
|
+
region: client.region
|
|
22040
|
+
};
|
|
22041
|
+
}
|
|
22042
|
+
const submit = await client.request("POST", "/mcp/search", body, { preSendSignal: ctx?.signal });
|
|
22043
|
+
const mocked = mockedSubmitPreview(submit, "leadbay_find_new_leads", client.region);
|
|
22044
|
+
if (mocked)
|
|
22045
|
+
return mocked;
|
|
22046
|
+
const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS);
|
|
22047
|
+
const snapshot = await snapshotAfterSubmit(client, submit.job_id, waitSeconds, ctx, params.count);
|
|
22048
|
+
const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
|
|
22049
|
+
const { leads, skipped } = splitItems(snapshot);
|
|
22050
|
+
return {
|
|
22051
|
+
job_id: submit.job_id,
|
|
22052
|
+
request_id: requestId,
|
|
22053
|
+
duplicate_submit: submit.duplicate ?? false,
|
|
22054
|
+
state: snapshot.job.state,
|
|
22055
|
+
done,
|
|
22056
|
+
summary: {
|
|
22057
|
+
// Named items_requested (not `requested`) to match qualify_leads and
|
|
22058
|
+
// the shared renderer, which reads summary.items_requested for the
|
|
22059
|
+
// "delivered X of the Y asked" clause.
|
|
22060
|
+
items_requested: submit.items_requested ?? params.count,
|
|
22061
|
+
delivered: snapshot.funnel.delivered ?? 0,
|
|
22062
|
+
delivered_callable: snapshot.funnel.delivered_callable ?? 0,
|
|
22063
|
+
delivered_title_only: snapshot.funnel.delivered_title_only ?? 0,
|
|
22064
|
+
degraded: snapshot.funnel.degraded ?? 0,
|
|
22065
|
+
stop_reason: snapshot.funnel.stop_reason ?? null
|
|
22066
|
+
},
|
|
22067
|
+
funnel: snapshot.funnel,
|
|
22068
|
+
leads,
|
|
22069
|
+
skipped,
|
|
22070
|
+
items_truncated: snapshot.items_truncated ?? false,
|
|
22071
|
+
// Top-level, not only inside next_poll: on a TERMINAL job that truncated,
|
|
22072
|
+
// next_poll used to be null, so the rendering rule telling the agent to
|
|
22073
|
+
// fetch the rest with `since: next_since` named a cursor the response did
|
|
22074
|
+
// not contain. The rows are paid for; the way to reach them cannot be
|
|
22075
|
+
// conditional on the job still running.
|
|
22076
|
+
next_since: snapshot.next_since ?? null,
|
|
22077
|
+
cost: snapshot.cost,
|
|
22078
|
+
estimated_cost: submit.estimated_cost,
|
|
22079
|
+
explain: snapshot.explain,
|
|
22080
|
+
still_running: !done,
|
|
22081
|
+
// A finished job can still owe rows: truncation means the drain stopped
|
|
22082
|
+
// early, so there is a follow-up action even when done is true. It is a
|
|
22083
|
+
// page fetch, not a wait, hence suggested_wait_seconds 0.
|
|
22084
|
+
next_poll: done && !(snapshot.items_truncated ?? false) ? null : {
|
|
22085
|
+
tool: "leadbay_lead_job_status",
|
|
22086
|
+
job_id: submit.job_id,
|
|
22087
|
+
// Hand the cursor forward so the follow-up poll continues
|
|
22088
|
+
// INCREMENTALLY instead of re-reading (and re-rendering) the
|
|
22089
|
+
// rows already delivered in this response.
|
|
22090
|
+
since: snapshot.next_since ?? null,
|
|
22091
|
+
suggested_wait_seconds: done ? 0 : 60
|
|
22092
|
+
},
|
|
22093
|
+
region: client.region
|
|
22094
|
+
};
|
|
22095
|
+
}
|
|
22096
|
+
};
|
|
22097
|
+
}
|
|
22098
|
+
});
|
|
22099
|
+
|
|
22100
|
+
// ../core/dist/composite/qualify-leads.js
|
|
22101
|
+
function normalizeLeadRefs(refs) {
|
|
22102
|
+
if (!Array.isArray(refs))
|
|
22103
|
+
return refs;
|
|
22104
|
+
return refs.map((ref) => {
|
|
22105
|
+
if (typeof ref !== "string")
|
|
22106
|
+
return ref;
|
|
22107
|
+
const value = ref.trim();
|
|
22108
|
+
if (!value)
|
|
22109
|
+
return ref;
|
|
22110
|
+
if (isUuidShaped(value))
|
|
22111
|
+
return { lead_id: value };
|
|
22112
|
+
return normalizeDomain(value) ? { website: value } : { name: value };
|
|
22113
|
+
});
|
|
22114
|
+
}
|
|
22115
|
+
function rejectMalformedLeadRefs(refs) {
|
|
22116
|
+
if (!Array.isArray(refs))
|
|
22117
|
+
return;
|
|
22118
|
+
const bad = [];
|
|
22119
|
+
refs.forEach((ref, i) => {
|
|
22120
|
+
if (ref === null || typeof ref !== "object" || Array.isArray(ref)) {
|
|
22121
|
+
bad.push(`${i} (not an object)`);
|
|
22122
|
+
return;
|
|
22123
|
+
}
|
|
22124
|
+
for (const field of LEAD_REF_FIELDS) {
|
|
22125
|
+
const value = ref[field];
|
|
22126
|
+
if (value !== void 0 && typeof value !== "string") {
|
|
22127
|
+
bad.push(`${i}.${field} (${value === null ? "null" : typeof value})`);
|
|
22128
|
+
}
|
|
22129
|
+
}
|
|
22130
|
+
});
|
|
22131
|
+
if (bad.length === 0)
|
|
22132
|
+
return;
|
|
22133
|
+
throw {
|
|
22134
|
+
error: true,
|
|
22135
|
+
code: "INVALID_LEAD_REF",
|
|
22136
|
+
message: `lead_refs has ${bad.length} invalid entr${bad.length === 1 ? "y" : "ies"}: ${bad.join(", ")}.`,
|
|
22137
|
+
hint: "Each ref is an object whose fields are STRINGS \u2014 {lead_id} | {website} | {name, location?} | {contact_id}. A bare string is accepted and reshaped; null, numbers, arrays and non-string field values are not. Fix or drop those entries and re-call."
|
|
22138
|
+
};
|
|
22139
|
+
}
|
|
22140
|
+
function text(value) {
|
|
22141
|
+
if (typeof value !== "string")
|
|
22142
|
+
return null;
|
|
22143
|
+
const v = value.trim().toLowerCase();
|
|
22144
|
+
return v ? v : null;
|
|
22145
|
+
}
|
|
22146
|
+
function derivedRequestId(params) {
|
|
22147
|
+
const refs = canonicalSet((params.lead_refs ?? []).map((r) => {
|
|
22148
|
+
const website = text(r.website);
|
|
22149
|
+
return [
|
|
22150
|
+
// UUIDs are case-insensitive to the backend, so an uppercase id and
|
|
22151
|
+
// its lowercase form are the same lead and must share a key.
|
|
22152
|
+
normalizeUuid(r.lead_id),
|
|
22153
|
+
normalizeUuid(r.contact_id),
|
|
22154
|
+
// Normalize the website the SAME way the resolver does, so a pasted
|
|
22155
|
+
// "https://Acme.com/" and a retry's "acme.com" resolve to one company
|
|
22156
|
+
// AND to one key. Fall back to the trimmed/lowercased raw value when
|
|
22157
|
+
// it is not domain-shaped, rather than dropping the field.
|
|
22158
|
+
website ? normalizeDomain(website) ?? website : null,
|
|
22159
|
+
text(r.name),
|
|
22160
|
+
text(r.location)
|
|
22161
|
+
];
|
|
22162
|
+
}));
|
|
22163
|
+
const shape = {
|
|
22164
|
+
refs,
|
|
22165
|
+
// The WHOLE selector, not just the job id: qualifying the first 50 of a
|
|
22166
|
+
// delivery job and then the next 50 are different batches, and collapsing
|
|
22167
|
+
// them to one key would make the second submit look like a duplicate and
|
|
22168
|
+
// leave those refs unqualified.
|
|
22169
|
+
prior: [
|
|
22170
|
+
// UUID-folded like the refs above: the backend resolves the same
|
|
22171
|
+
// delivery job regardless of casing, so casing alone must not fork
|
|
22172
|
+
// the key and re-run a paid batch.
|
|
22173
|
+
normalizeUuid(params.prior_deliveries?.job_id),
|
|
22174
|
+
params.prior_deliveries?.since ?? null,
|
|
22175
|
+
params.prior_deliveries?.limit ?? null
|
|
22176
|
+
],
|
|
22177
|
+
// Canonicalize to the value the BACKEND will apply, so an approval that
|
|
22178
|
+
// omits a field and a retry that passes that field's documented default
|
|
22179
|
+
// derive the same key instead of launching a second paid job.
|
|
22180
|
+
qualify: params.qualify !== false,
|
|
22181
|
+
channels: canonicalSet(params.channels),
|
|
22182
|
+
contact_titles: canonicalLabelSet(params.contact_titles),
|
|
22183
|
+
// Same canonicalization as the search path: with contact_titles present
|
|
22184
|
+
// the backend applies `prefer` when the field is omitted, so an approval
|
|
22185
|
+
// that omits it and a retry that passes the materialized default describe
|
|
22186
|
+
// identical work. Hashing the omission as null forked the key and let the
|
|
22187
|
+
// retry escape dedupe into a second paid qualification / channel purchase.
|
|
22188
|
+
title_gate: params.title_gate ?? ((params.contact_titles?.length ?? 0) > 0 ? "prefer" : null),
|
|
22189
|
+
// The cap is part of the approval: raising it after a stop_reason:max_cost
|
|
22190
|
+
// is a NEW approved run, and must not dedupe onto the capped job.
|
|
22191
|
+
max_cost: params.max_cost ?? null,
|
|
22192
|
+
// Same for the output language — re-running the batch in another language
|
|
22193
|
+
// must not return the earlier job with evidence in the previous one.
|
|
22194
|
+
lang: params.lang ?? null
|
|
22195
|
+
};
|
|
22196
|
+
return derivedKey("qualify-auto", shape);
|
|
22197
|
+
}
|
|
22198
|
+
var DEFAULT_WAIT_SECONDS2, LEAD_REF_FIELDS, qualifyLeads;
|
|
22199
|
+
var init_qualify_leads = __esm({
|
|
22200
|
+
"../core/dist/composite/qualify-leads.js"() {
|
|
22201
|
+
"use strict";
|
|
22202
|
+
init_mcp_job_helpers();
|
|
22203
|
+
init_import_leads();
|
|
22204
|
+
init_tool_descriptions_generated();
|
|
22205
|
+
DEFAULT_WAIT_SECONDS2 = 45;
|
|
22206
|
+
LEAD_REF_FIELDS = [
|
|
22207
|
+
"lead_id",
|
|
22208
|
+
"website",
|
|
22209
|
+
"name",
|
|
22210
|
+
"location",
|
|
22211
|
+
"contact_id"
|
|
22212
|
+
];
|
|
22213
|
+
qualifyLeads = {
|
|
22214
|
+
name: "leadbay_qualify_leads",
|
|
22215
|
+
annotations: {
|
|
22216
|
+
title: "Qualify + get the right contact on known leads",
|
|
22217
|
+
readOnlyHint: false,
|
|
22218
|
+
// Spends real money (fresh qualification, and email/phone reveals when
|
|
22219
|
+
// channels are requested), same as bulk_qualify_leads / enrich-titles.
|
|
22220
|
+
// Hosts and approval layers key their prompts off this flag, so a paid
|
|
22221
|
+
// job submitter must not advertise itself as harmless.
|
|
22222
|
+
destructiveHint: true,
|
|
22223
|
+
idempotentHint: false,
|
|
22224
|
+
openWorldHint: true
|
|
22225
|
+
},
|
|
22226
|
+
write: true,
|
|
22227
|
+
description: leadbay_qualify_leads,
|
|
22228
|
+
inputSchema: {
|
|
19968
22229
|
type: "object",
|
|
19969
|
-
description: "Multiple return shapes by status. dry_run, applied (with optional clarified_via_elicit), or clarification_pending.",
|
|
19970
22230
|
properties: {
|
|
19971
|
-
|
|
19972
|
-
|
|
19973
|
-
|
|
19974
|
-
|
|
22231
|
+
lead_refs: {
|
|
22232
|
+
type: "array",
|
|
22233
|
+
description: "Companies to qualify (max 500). Each ref needs at least one identifying field. Duplicate lead_ids collapse into one item.",
|
|
22234
|
+
items: {
|
|
22235
|
+
type: "object",
|
|
22236
|
+
properties: {
|
|
22237
|
+
lead_id: { type: "string", description: "Leadbay lead UUID." },
|
|
22238
|
+
website: { type: "string" },
|
|
22239
|
+
name: { type: "string" },
|
|
22240
|
+
location: {
|
|
22241
|
+
type: "string",
|
|
22242
|
+
description: "Disambiguates name-only refs (city/region)."
|
|
22243
|
+
},
|
|
22244
|
+
contact_id: {
|
|
22245
|
+
type: "string",
|
|
22246
|
+
description: "Stable lead_contact id from a prior result \u2014 enrichment then targets EXACTLY this person, never a re-match."
|
|
22247
|
+
}
|
|
22248
|
+
},
|
|
22249
|
+
additionalProperties: false
|
|
22250
|
+
}
|
|
19975
22251
|
},
|
|
19976
|
-
|
|
19977
|
-
type: "
|
|
19978
|
-
description: "'
|
|
22252
|
+
prior_deliveries: {
|
|
22253
|
+
type: "object",
|
|
22254
|
+
description: "Selector expanding the org's past MCP deliveries into refs \u2014 billed leads stay re-readable after result expiry. Combine with lead_refs or use alone.",
|
|
22255
|
+
properties: {
|
|
22256
|
+
job_id: { type: "string" },
|
|
22257
|
+
since: { type: "string", description: "ISO instant lower bound." },
|
|
22258
|
+
limit: { type: "number" }
|
|
22259
|
+
},
|
|
22260
|
+
additionalProperties: false
|
|
19979
22261
|
},
|
|
19980
|
-
|
|
22262
|
+
qualify: {
|
|
19981
22263
|
type: "boolean",
|
|
19982
|
-
description: "
|
|
22264
|
+
description: "Fresh AI qualification (default true; ~94 cost_cents per lead needing fresh research+scoring, cache-free when a fresh dossier exists). Owned disqualified leads come back WITH their negative evidence."
|
|
19983
22265
|
},
|
|
19984
|
-
|
|
19985
|
-
type: "
|
|
19986
|
-
|
|
22266
|
+
contact_titles: {
|
|
22267
|
+
type: "array",
|
|
22268
|
+
items: { type: "string" },
|
|
22269
|
+
description: "Wanted decision-maker titles (max 10), matched semantically."
|
|
19987
22270
|
},
|
|
19988
|
-
|
|
22271
|
+
title_gate: {
|
|
19989
22272
|
type: "string",
|
|
19990
|
-
|
|
22273
|
+
enum: ["strict", "prefer"],
|
|
22274
|
+
description: "strict = only items with a matching known contact deliver a contact; prefer = matched first, rest flagged."
|
|
19991
22275
|
},
|
|
19992
|
-
|
|
19993
|
-
type: "
|
|
19994
|
-
|
|
22276
|
+
channels: {
|
|
22277
|
+
type: "array",
|
|
22278
|
+
items: { type: "string", enum: ["email", "phone"] },
|
|
22279
|
+
description: "Channels to PURCHASE (email 25c, phone 250c, success-only, already-owned values are free). Empty = free identity tier."
|
|
19995
22280
|
},
|
|
19996
|
-
|
|
22281
|
+
max_cost: {
|
|
22282
|
+
type: "number",
|
|
22283
|
+
description: "Spend cap in cost_cents (plan-tier default when unset)."
|
|
22284
|
+
},
|
|
22285
|
+
request_id: {
|
|
19997
22286
|
type: "string",
|
|
19998
|
-
description: "
|
|
22287
|
+
description: "Recommended idempotency key \u2014 REUSE the same value when retrying the same batch so a retry returns the SAME job instead of re-spending."
|
|
19999
22288
|
},
|
|
20000
|
-
|
|
20001
|
-
|
|
22289
|
+
lang: { type: "string", description: "Output language (default: user's language)." },
|
|
22290
|
+
confirm: {
|
|
22291
|
+
type: "boolean",
|
|
22292
|
+
description: "Explicit spend decision for the PAID work (fresh qualification and/or channel purchases). true = the user approved the quote, go ahead. false = a veto (returns mode:'needs_confirmation', spends nothing). Omitted on a paid call \u2192 the tool withholds the submit and returns a free quote to show the user first. A fully FREE call (qualify:false and no channels) needs no confirm."
|
|
22293
|
+
},
|
|
22294
|
+
dry_run: {
|
|
22295
|
+
type: "boolean",
|
|
22296
|
+
description: "Validate + worst-case cost + quota forecast. No job, no spend."
|
|
22297
|
+
},
|
|
22298
|
+
wait_seconds: {
|
|
22299
|
+
type: "number",
|
|
22300
|
+
description: "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Large or research-heavy batches can take minutes \u2014 the result then carries still_running:true and the job_id for leadbay_lead_job_status."
|
|
22301
|
+
}
|
|
22302
|
+
},
|
|
22303
|
+
additionalProperties: false
|
|
20002
22304
|
},
|
|
20003
22305
|
execute: async (client, params, ctx) => {
|
|
20004
|
-
|
|
20005
|
-
|
|
20006
|
-
|
|
20007
|
-
|
|
20008
|
-
|
|
20009
|
-
|
|
20010
|
-
|
|
20011
|
-
|
|
20012
|
-
|
|
20013
|
-
const
|
|
20014
|
-
|
|
20015
|
-
|
|
20016
|
-
|
|
20017
|
-
|
|
20018
|
-
|
|
20019
|
-
|
|
20020
|
-
|
|
20021
|
-
|
|
20022
|
-
|
|
20023
|
-
|
|
20024
|
-
|
|
20025
|
-
|
|
20026
|
-
|
|
20027
|
-
|
|
22306
|
+
params = coerceArrayParams(params, [
|
|
22307
|
+
"lead_refs",
|
|
22308
|
+
"contact_titles",
|
|
22309
|
+
"channels"
|
|
22310
|
+
]);
|
|
22311
|
+
params = { ...params, lead_refs: normalizeLeadRefs(params.lead_refs) };
|
|
22312
|
+
rejectMalformedLeadRefs(params.lead_refs);
|
|
22313
|
+
rejectOversizedLeadRefs(params.lead_refs);
|
|
22314
|
+
const qualify = readSpendFlag(params.qualify, "qualify");
|
|
22315
|
+
const dryRun = readSpendFlag(params.dry_run, "dry_run");
|
|
22316
|
+
const confirm = readSpendFlag(params.confirm, "confirm");
|
|
22317
|
+
params = { ...params, qualify, dry_run: dryRun, confirm };
|
|
22318
|
+
const buysChannels = (params.channels?.length ?? 0) > 0;
|
|
22319
|
+
const buysQualification = qualify !== false;
|
|
22320
|
+
const isPaid = buysQualification || buysChannels;
|
|
22321
|
+
const vetoed = confirm === false;
|
|
22322
|
+
const consented = !vetoed && confirm === true;
|
|
22323
|
+
const requestId = presentRequestId(params.request_id) ?? (isPaid ? derivedRequestId(params) : void 0);
|
|
22324
|
+
const body = compactBody({
|
|
22325
|
+
lead_refs: params.lead_refs,
|
|
22326
|
+
prior_deliveries: params.prior_deliveries,
|
|
22327
|
+
qualify,
|
|
22328
|
+
contact_titles: params.contact_titles,
|
|
22329
|
+
title_gate: params.title_gate,
|
|
22330
|
+
channels: params.channels,
|
|
22331
|
+
max_cost: params.max_cost,
|
|
22332
|
+
request_id: requestId,
|
|
22333
|
+
lang: params.lang,
|
|
22334
|
+
dry_run: dryRun
|
|
20028
22335
|
});
|
|
20029
|
-
|
|
20030
|
-
|
|
20031
|
-
|
|
20032
|
-
|
|
20033
|
-
|
|
20034
|
-
|
|
20035
|
-
|
|
20036
|
-
|
|
20037
|
-
|
|
20038
|
-
if (c.created_at) {
|
|
20039
|
-
const createdMs = Date.parse(c.created_at);
|
|
20040
|
-
if (Number.isFinite(createdMs) && createdMs < postedAt - STALE_GUARD_MS) {
|
|
20041
|
-
ctx?.logger?.warn?.(`refine_prompt: stale clarification (created_at=${c.created_at}, posted=${new Date(postedAt).toISOString()}) \u2014 ignoring`);
|
|
20042
|
-
continue;
|
|
20043
|
-
}
|
|
20044
|
-
}
|
|
20045
|
-
clarification = c;
|
|
20046
|
-
break;
|
|
20047
|
-
}
|
|
20048
|
-
} catch (err) {
|
|
20049
|
-
ctx?.logger?.warn?.(`refine_prompt: clarification poll error: ${err?.message}`);
|
|
20050
|
-
}
|
|
20051
|
-
}
|
|
20052
|
-
if (clarification) {
|
|
20053
|
-
if (ctx?.elicit) {
|
|
20054
|
-
const opts = clarification.options ?? [];
|
|
20055
|
-
const requestedSchema = opts.length > 0 ? {
|
|
20056
|
-
type: "object",
|
|
20057
|
-
properties: {
|
|
20058
|
-
option_id: {
|
|
20059
|
-
type: "string",
|
|
20060
|
-
title: "Pick one",
|
|
20061
|
-
description: "Choose the option that best matches your intent.",
|
|
20062
|
-
enum: opts.filter((o) => o.id).map((o) => o.id),
|
|
20063
|
-
enumNames: opts.filter((o) => o.id).map((o) => o.label)
|
|
20064
|
-
}
|
|
20065
|
-
},
|
|
20066
|
-
required: ["option_id"]
|
|
20067
|
-
} : {
|
|
20068
|
-
type: "object",
|
|
20069
|
-
properties: {
|
|
20070
|
-
text_answer: {
|
|
20071
|
-
type: "string",
|
|
20072
|
-
title: "Answer",
|
|
20073
|
-
description: "Free-text answer to the clarification. Plain English."
|
|
20074
|
-
}
|
|
20075
|
-
},
|
|
20076
|
-
required: ["text_answer"]
|
|
20077
|
-
};
|
|
20078
|
-
try {
|
|
20079
|
-
const elicited = await ctx.elicit({
|
|
20080
|
-
message: clarification.question,
|
|
20081
|
-
requestedSchema
|
|
20082
|
-
});
|
|
20083
|
-
if (elicited.action === "accept" && elicited.content) {
|
|
20084
|
-
const body = typeof elicited.content.option_id === "string" ? { option_id: elicited.content.option_id } : typeof elicited.content.text_answer === "string" ? { text_answer: elicited.content.text_answer } : null;
|
|
20085
|
-
if (body) {
|
|
20086
|
-
try {
|
|
20087
|
-
await client.requestVoid("POST", `/organizations/${orgId}/pick_clarification`, body);
|
|
20088
|
-
client.invalidateMe();
|
|
20089
|
-
return {
|
|
20090
|
-
status: "applied",
|
|
20091
|
-
clarified_via_elicit: true,
|
|
20092
|
-
computing_intelligence: true,
|
|
20093
|
-
message: "Prompt set + clarification answered via the client's elicitation UI. Leadbay is regenerating intelligence.",
|
|
20094
|
-
_meta: { region: client.region }
|
|
20095
|
-
};
|
|
20096
|
-
} catch (err) {
|
|
20097
|
-
ctx?.logger?.warn?.(`refine_prompt: pick_clarification POST failed after elicit: ${err?.message ?? err?.code ?? err}`);
|
|
20098
|
-
}
|
|
20099
|
-
}
|
|
20100
|
-
}
|
|
20101
|
-
} catch (err) {
|
|
20102
|
-
ctx?.logger?.warn?.(`refine_prompt: elicit failed: ${err?.message ?? err?.code ?? err} \u2014 falling back to telephone path`);
|
|
20103
|
-
}
|
|
20104
|
-
}
|
|
22336
|
+
if (dryRun === true) {
|
|
22337
|
+
const forecast = await client.request("POST", "/mcp/qualify", body);
|
|
22338
|
+
return { dry_run: true, ...forecast, region: client.region };
|
|
22339
|
+
}
|
|
22340
|
+
if (isPaid && !consented) {
|
|
22341
|
+
const forecast = vetoed ? null : await client.request("POST", "/mcp/qualify", {
|
|
22342
|
+
...body,
|
|
22343
|
+
dry_run: true
|
|
22344
|
+
});
|
|
20105
22345
|
return {
|
|
20106
|
-
|
|
20107
|
-
|
|
20108
|
-
|
|
20109
|
-
|
|
22346
|
+
mode: "needs_confirmation",
|
|
22347
|
+
submitted: false,
|
|
22348
|
+
vetoed,
|
|
22349
|
+
paid_because: [
|
|
22350
|
+
buysQualification ? "qualify is on (backend default is true \u2014 pass qualify:false to keep it free)" : null,
|
|
22351
|
+
buysChannels ? `channels requested: ${params.channels.join(", ")}` : null
|
|
22352
|
+
].filter(Boolean),
|
|
22353
|
+
quote: forecast,
|
|
22354
|
+
estimated_cost: forecast?.estimated_cost ?? null,
|
|
22355
|
+
items_requested: forecast?.items_requested ?? null,
|
|
22356
|
+
hint: vetoed ? "confirm:false vetoed the spend \u2014 nothing was submitted. Re-call with confirm:true to proceed, or qualify:false with no channels for a free pass." : "Show the user this worst-case quote and get an explicit go-ahead, then re-call with confirm:true. For a free pass instead: qualify:false and no channels.",
|
|
22357
|
+
region: client.region
|
|
20110
22358
|
};
|
|
20111
22359
|
}
|
|
22360
|
+
const submit = await client.request("POST", "/mcp/qualify", body, { preSendSignal: ctx?.signal });
|
|
22361
|
+
const mocked = mockedSubmitPreview(submit, "leadbay_qualify_leads", client.region);
|
|
22362
|
+
if (mocked)
|
|
22363
|
+
return mocked;
|
|
22364
|
+
const waitSeconds = clampWaitSeconds(params.wait_seconds, DEFAULT_WAIT_SECONDS2);
|
|
22365
|
+
const snapshot = await snapshotAfterSubmit(client, submit.job_id, waitSeconds, ctx, submit.items_requested);
|
|
22366
|
+
const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
|
|
22367
|
+
const indexed = submit.duplicate ?? false ? remapInputIndexes(snapshot.items, params.lead_refs) : { items: snapshot.items, remapped: true };
|
|
22368
|
+
const view = { ...snapshot, items: indexed.items };
|
|
20112
22369
|
return {
|
|
20113
|
-
|
|
20114
|
-
|
|
20115
|
-
|
|
20116
|
-
|
|
22370
|
+
job_id: submit.job_id,
|
|
22371
|
+
// Echo the key actually sent, so a retry can reuse it verbatim.
|
|
22372
|
+
request_id: requestId ?? null,
|
|
22373
|
+
duplicate_submit: submit.duplicate ?? false,
|
|
22374
|
+
state: snapshot.job.state,
|
|
22375
|
+
done,
|
|
22376
|
+
summary: {
|
|
22377
|
+
refs_submitted: params.lead_refs?.length ?? 0,
|
|
22378
|
+
items_requested: submit.items_requested,
|
|
22379
|
+
delivered: snapshot.funnel.delivered ?? 0,
|
|
22380
|
+
delivered_callable: snapshot.funnel.delivered_callable ?? 0,
|
|
22381
|
+
degraded: snapshot.funnel.degraded ?? 0,
|
|
22382
|
+
resolved: snapshot.funnel.resolved ?? null,
|
|
22383
|
+
not_in_universe: snapshot.funnel.not_in_universe ?? null,
|
|
22384
|
+
stop_reason: snapshot.funnel.stop_reason ?? null
|
|
22385
|
+
},
|
|
22386
|
+
funnel: snapshot.funnel,
|
|
22387
|
+
// Per-item outcomes in input order where known (ref.input_indexes maps
|
|
22388
|
+
// back to the caller's lead_refs positions). Items carry the full
|
|
22389
|
+
// QualifiedLead payload when delivered/degraded, and an honest
|
|
22390
|
+
// status_reason (not_in_universe, low_confidence_identity, ...) when
|
|
22391
|
+
// skipped — a skip is an ANSWER about that ref, not an error.
|
|
22392
|
+
items: view.items,
|
|
22393
|
+
// On a duplicate submit whose indexes could not be re-pointed at this
|
|
22394
|
+
// caller's refs, input_indexes are null rather than stale — match items
|
|
22395
|
+
// by `ref.requested_as` / `lead_id` instead.
|
|
22396
|
+
input_indexes_remapped: submit.duplicate ?? false ? indexed.remapped : null,
|
|
22397
|
+
// ...and the same outcomes pre-split, because the shared
|
|
22398
|
+
// rendering/lead-delivery-table contract this tool's description
|
|
22399
|
+
// mandates reads deliveries from `leads[]` and skips from `skipped[]`.
|
|
22400
|
+
// Returning only `items` left an agent following the RENDER block with
|
|
22401
|
+
// two empty tables; the sibling tools (find_new_leads, lead_job_status)
|
|
22402
|
+
// both split. `items` stays for input-order per-ref mapping.
|
|
22403
|
+
...splitItems(view),
|
|
22404
|
+
items_truncated: snapshot.items_truncated ?? false,
|
|
22405
|
+
// Top-level, not only inside next_poll: a TERMINAL job that truncated had
|
|
22406
|
+
// next_poll null, so the rendering rule pointing at `since: next_since`
|
|
22407
|
+
// named a cursor the response did not carry.
|
|
22408
|
+
next_since: snapshot.next_since ?? null,
|
|
22409
|
+
cost: snapshot.cost,
|
|
22410
|
+
estimated_cost: submit.estimated_cost,
|
|
22411
|
+
explain: snapshot.explain,
|
|
22412
|
+
still_running: !done,
|
|
22413
|
+
// A finished job can still owe rows: truncation means the drain stopped
|
|
22414
|
+
// early, so there is a follow-up action even when done is true.
|
|
22415
|
+
next_poll: done && !(snapshot.items_truncated ?? false) ? null : {
|
|
22416
|
+
tool: "leadbay_lead_job_status",
|
|
22417
|
+
job_id: submit.job_id,
|
|
22418
|
+
// Hand the cursor forward so the follow-up poll continues
|
|
22419
|
+
// INCREMENTALLY instead of re-reading (and re-rendering) the
|
|
22420
|
+
// rows already delivered in this response.
|
|
22421
|
+
since: snapshot.next_since ?? null,
|
|
22422
|
+
suggested_wait_seconds: done ? 0 : 60
|
|
22423
|
+
},
|
|
22424
|
+
region: client.region
|
|
20117
22425
|
};
|
|
20118
22426
|
}
|
|
20119
22427
|
};
|
|
20120
22428
|
}
|
|
20121
22429
|
});
|
|
20122
22430
|
|
|
20123
|
-
// ../core/dist/composite/
|
|
20124
|
-
var
|
|
20125
|
-
var
|
|
20126
|
-
"../core/dist/composite/
|
|
22431
|
+
// ../core/dist/composite/lead-job-status.js
|
|
22432
|
+
var leadJobStatus;
|
|
22433
|
+
var init_lead_job_status = __esm({
|
|
22434
|
+
"../core/dist/composite/lead-job-status.js"() {
|
|
20127
22435
|
"use strict";
|
|
22436
|
+
init_mcp_job_helpers();
|
|
20128
22437
|
init_tool_descriptions_generated();
|
|
20129
|
-
|
|
20130
|
-
name: "
|
|
22438
|
+
leadJobStatus = {
|
|
22439
|
+
name: "leadbay_lead_job_status",
|
|
20131
22440
|
annotations: {
|
|
20132
|
-
title: "
|
|
22441
|
+
title: "Poll a lead-delivery job",
|
|
20133
22442
|
readOnlyHint: true,
|
|
20134
22443
|
destructiveHint: false,
|
|
20135
22444
|
idempotentHint: true,
|
|
20136
22445
|
openWorldHint: true
|
|
20137
22446
|
},
|
|
20138
|
-
description:
|
|
22447
|
+
description: leadbay_lead_job_status,
|
|
20139
22448
|
inputSchema: {
|
|
20140
22449
|
type: "object",
|
|
20141
22450
|
properties: {
|
|
20142
|
-
|
|
20143
|
-
type: "
|
|
20144
|
-
description: "
|
|
22451
|
+
job_id: {
|
|
22452
|
+
type: "string",
|
|
22453
|
+
description: "The job_id returned by leadbay_find_new_leads or leadbay_qualify_leads."
|
|
22454
|
+
},
|
|
22455
|
+
since: {
|
|
22456
|
+
type: "string",
|
|
22457
|
+
description: "Opaque cursor from a previous poll's next_since \u2014 returns only items emitted after it. Omit for the full snapshot."
|
|
20145
22458
|
},
|
|
20146
22459
|
limit: {
|
|
20147
22460
|
type: "number",
|
|
20148
|
-
description: "
|
|
20149
|
-
}
|
|
20150
|
-
},
|
|
20151
|
-
additionalProperties: false
|
|
20152
|
-
},
|
|
20153
|
-
outputSchema: {
|
|
20154
|
-
type: "object",
|
|
20155
|
-
properties: {
|
|
20156
|
-
lens: {
|
|
20157
|
-
type: "object",
|
|
20158
|
-
properties: { id: { type: "number" } }
|
|
22461
|
+
description: "Items per page, 1-100 (default 100; pages are auto-collected)."
|
|
20159
22462
|
},
|
|
20160
|
-
|
|
20161
|
-
type: "
|
|
20162
|
-
description: "
|
|
20163
|
-
items: { type: "object" }
|
|
22463
|
+
wait_seconds: {
|
|
22464
|
+
type: "number",
|
|
22465
|
+
description: "0 (default) = instant snapshot. >0 = keep polling up to this many seconds until the job is terminal \u2014 use ~60 when the user asked to wait for results."
|
|
20164
22466
|
}
|
|
20165
22467
|
},
|
|
20166
|
-
required: ["
|
|
22468
|
+
required: ["job_id"],
|
|
22469
|
+
additionalProperties: false
|
|
20167
22470
|
},
|
|
20168
|
-
execute: async (client, params) => {
|
|
20169
|
-
const
|
|
20170
|
-
const
|
|
20171
|
-
const
|
|
22471
|
+
execute: async (client, params, ctx) => {
|
|
22472
|
+
const waitSeconds = clampWaitSeconds(params.wait_seconds, 0);
|
|
22473
|
+
const snapshot = waitSeconds > 0 ? await waitForJob(client, params.job_id, waitSeconds, ctx, void 0, params.since, params.limit) : await collectJobSnapshot(client, params.job_id, params.since, params.limit, ctx?.signal);
|
|
22474
|
+
const done = TERMINAL_JOB_STATES.has(snapshot.job.state);
|
|
22475
|
+
const { leads, skipped } = splitItems(snapshot);
|
|
20172
22476
|
return {
|
|
20173
|
-
|
|
20174
|
-
|
|
22477
|
+
job_id: params.job_id,
|
|
22478
|
+
state: snapshot.job.state,
|
|
22479
|
+
done,
|
|
22480
|
+
funnel: snapshot.funnel,
|
|
22481
|
+
leads,
|
|
22482
|
+
skipped,
|
|
22483
|
+
// Surfaced so the renderer never presents a partial page set as the whole
|
|
22484
|
+
// result: `leads` is a prefix, and next_since resumes it.
|
|
22485
|
+
items_truncated: snapshot.items_truncated ?? false,
|
|
22486
|
+
next_since: snapshot.next_since ?? null,
|
|
22487
|
+
cost: snapshot.cost,
|
|
22488
|
+
explain: snapshot.explain,
|
|
22489
|
+
still_running: !done,
|
|
22490
|
+
// Truncation leaves rows unread even on a finished job, so the follow-up
|
|
22491
|
+
// action survives `done` — same rule as the two submit tools.
|
|
22492
|
+
next_poll: done && !(snapshot.items_truncated ?? false) ? null : {
|
|
22493
|
+
tool: "leadbay_lead_job_status",
|
|
22494
|
+
job_id: params.job_id,
|
|
22495
|
+
// Same incremental handoff as the submit tools — following
|
|
22496
|
+
// next_poll without the cursor re-reads the rows just returned.
|
|
22497
|
+
since: snapshot.next_since ?? null,
|
|
22498
|
+
suggested_wait_seconds: done ? 0 : 60
|
|
22499
|
+
},
|
|
22500
|
+
region: client.region
|
|
20175
22501
|
};
|
|
20176
22502
|
}
|
|
20177
22503
|
};
|
|
@@ -21587,8 +23913,8 @@ var init_send_feedback = __esm({
|
|
|
21587
23913
|
}
|
|
21588
23914
|
},
|
|
21589
23915
|
execute: async (client, params, ctx) => {
|
|
21590
|
-
const
|
|
21591
|
-
if (!
|
|
23916
|
+
const text2 = typeof params.message === "string" ? params.message.trim() : "";
|
|
23917
|
+
if (!text2) {
|
|
21592
23918
|
return {
|
|
21593
23919
|
error: true,
|
|
21594
23920
|
code: "BAD_INPUT",
|
|
@@ -21596,7 +23922,7 @@ var init_send_feedback = __esm({
|
|
|
21596
23922
|
hint: "Ask the user what they'd like to tell the Leadbay team, then call again with their words in `message`."
|
|
21597
23923
|
};
|
|
21598
23924
|
}
|
|
21599
|
-
const message =
|
|
23925
|
+
const message = text2.length > MESSAGE_MAX2 ? `${text2.slice(0, MESSAGE_MAX2 - 1)}\u2026` : text2;
|
|
21600
23926
|
if (!ctx?.sendFeedback) {
|
|
21601
23927
|
return {
|
|
21602
23928
|
sent: false,
|
|
@@ -21734,6 +24060,7 @@ __export(dist_exports, {
|
|
|
21734
24060
|
enrichContacts: () => enrichContacts,
|
|
21735
24061
|
enrichTitles: () => enrichTitles,
|
|
21736
24062
|
extendLens: () => extendLens,
|
|
24063
|
+
findNewLeads: () => findNewLeads,
|
|
21737
24064
|
followupsMap: () => followupsMap,
|
|
21738
24065
|
formatLoginError: () => formatLoginError,
|
|
21739
24066
|
getClarification: () => getClarification,
|
|
@@ -21764,6 +24091,7 @@ __export(dist_exports, {
|
|
|
21764
24091
|
inferKind: () => inferKind,
|
|
21765
24092
|
launchBulkEnrichment: () => launchBulkEnrichment,
|
|
21766
24093
|
launchFingerprint: () => launchFingerprint,
|
|
24094
|
+
leadJobStatus: () => leadJobStatus,
|
|
21767
24095
|
likeLead: () => likeLead,
|
|
21768
24096
|
listCampaigns: () => listCampaigns,
|
|
21769
24097
|
listLenses: () => listLenses,
|
|
@@ -21771,6 +24099,8 @@ __export(dist_exports, {
|
|
|
21771
24099
|
listMappableFields: () => listMappableFields,
|
|
21772
24100
|
listSectors: () => listSectors,
|
|
21773
24101
|
login: () => login,
|
|
24102
|
+
mcpFirstDeliveryAllTools: () => mcpFirstDeliveryAllTools,
|
|
24103
|
+
mcpFirstDeliveryTools: () => mcpFirstDeliveryTools,
|
|
21774
24104
|
openBillingPortal: () => openBillingPortal,
|
|
21775
24105
|
pickClarification: () => pickClarification,
|
|
21776
24106
|
prepareOutreach: () => prepareOutreach,
|
|
@@ -21779,6 +24109,7 @@ __export(dist_exports, {
|
|
|
21779
24109
|
pullFollowups: () => pullFollowups,
|
|
21780
24110
|
pullLeads: () => pullLeads,
|
|
21781
24111
|
qualifyLead: () => qualifyLead,
|
|
24112
|
+
qualifyLeads: () => qualifyLeads,
|
|
21782
24113
|
qualifyStatus: () => qualifyStatus,
|
|
21783
24114
|
recallLaunch: () => recallLaunch,
|
|
21784
24115
|
recallOrderedTitles: () => recallOrderedTitles,
|
|
@@ -21815,7 +24146,7 @@ __export(dist_exports, {
|
|
|
21815
24146
|
updateLens: () => updateLens,
|
|
21816
24147
|
updateLensFilter: () => updateLensFilter
|
|
21817
24148
|
});
|
|
21818
|
-
var granularReadTools, granularWriteTools, granularTools, compositeReadTools, compositeWriteTools, compositeTools, tools;
|
|
24149
|
+
var granularReadTools, granularWriteTools, granularTools, compositeReadTools, mcpFirstDeliveryTools, mcpFirstDeliveryAllTools, compositeWriteTools, compositeTools, tools;
|
|
21819
24150
|
var init_dist = __esm({
|
|
21820
24151
|
"../core/dist/index.js"() {
|
|
21821
24152
|
"use strict";
|
|
@@ -21913,6 +24244,9 @@ var init_dist = __esm({
|
|
|
21913
24244
|
init_adjust_audience();
|
|
21914
24245
|
init_refine_prompt();
|
|
21915
24246
|
init_seed_candidates();
|
|
24247
|
+
init_find_new_leads();
|
|
24248
|
+
init_qualify_leads();
|
|
24249
|
+
init_lead_job_status();
|
|
21916
24250
|
init_extend_lens();
|
|
21917
24251
|
init_my_lenses();
|
|
21918
24252
|
init_new_lens();
|
|
@@ -21982,6 +24316,13 @@ var init_dist = __esm({
|
|
|
21982
24316
|
t.advanced = true;
|
|
21983
24317
|
});
|
|
21984
24318
|
compositeReadTools = [
|
|
24319
|
+
// Poll surface for the MCP-first lead-delivery jobs (find_new_leads /
|
|
24320
|
+
// qualify_leads). Read-only snapshot of a backend-owned job. The backend
|
|
24321
|
+
// routes (`POST /1.6/mcp/search`, `POST /1.6/mcp/qualify`,
|
|
24322
|
+
// `GET /1.6/mcp/jobs/{id}`) shipped to production in backend v3.22.0
|
|
24323
|
+
// (2026-08-22) and were verified live on both regions, so the opt-in
|
|
24324
|
+
// LEADBAY_MCP_LEAD_DELIVERY flag that held these three back is gone.
|
|
24325
|
+
leadJobStatus,
|
|
21985
24326
|
pullLeads,
|
|
21986
24327
|
pullFollowups,
|
|
21987
24328
|
followupsMap,
|
|
@@ -22044,6 +24385,11 @@ var init_dist = __esm({
|
|
|
22044
24385
|
// leadbay_new_lens / leadbay_adjust_audience). Without it the agent can only
|
|
22045
24386
|
// probe sectors by trial-and-error or ask the user to read the web UI.
|
|
22046
24387
|
listSectors,
|
|
24388
|
+
// listLocations, same rationale on the geography axis. The delivery tools
|
|
24389
|
+
// reject an unresolvable filters.locations with a 400 naming the value and
|
|
24390
|
+
// send the agent here to look up the real admin area — a recovery path that
|
|
24391
|
+
// only works if the lookup is reachable without LEADBAY_MCP_ADVANCED=1.
|
|
24392
|
+
listLocations,
|
|
22047
24393
|
// Billing / top-up tools — granular-shaped but ALWAYS exposed because
|
|
22048
24394
|
// they're the canonical recovery path from a QUOTA_EXCEEDED wall. If
|
|
22049
24395
|
// they were gated behind LEADBAY_MCP_ADVANCED=1 the agent would
|
|
@@ -22073,7 +24419,21 @@ var init_dist = __esm({
|
|
|
22073
24419
|
// tools/) so it carries no _triggered_by mandate for a kit fetch.
|
|
22074
24420
|
artifactKit
|
|
22075
24421
|
];
|
|
24422
|
+
mcpFirstDeliveryTools = [
|
|
24423
|
+
// Write-tier: submits create server-side jobs that can spend money
|
|
24424
|
+
// (qualification research, channel purchase) and claim novelty in the
|
|
24425
|
+
// org's delivery ledger — same posture as the other spending composites.
|
|
24426
|
+
// The FREE tier (qualify:false, channels:[]) is the default ask, and a paid
|
|
24427
|
+
// call is withheld in code until `confirm: true`.
|
|
24428
|
+
findNewLeads,
|
|
24429
|
+
qualifyLeads
|
|
24430
|
+
];
|
|
24431
|
+
mcpFirstDeliveryAllTools = [
|
|
24432
|
+
...mcpFirstDeliveryTools,
|
|
24433
|
+
leadJobStatus
|
|
24434
|
+
];
|
|
22076
24435
|
compositeWriteTools = [
|
|
24436
|
+
...mcpFirstDeliveryTools,
|
|
22077
24437
|
bulkQualifyLeads,
|
|
22078
24438
|
enrichTitles,
|
|
22079
24439
|
adjustAudience,
|
|
@@ -23464,6 +25824,170 @@ After I answer, call \`leadbay_report_outreach({lead_id: '{{arg:lead_id}}', note
|
|
|
23464
25824
|
# PHASE 3 \u2014 CONFIRM
|
|
23465
25825
|
Tell me the outreach was logged, name the verification.source used, and surface the response's \`outreach_id\` if present so I can refer back to it.
|
|
23466
25826
|
`;
|
|
25827
|
+
var leadbay_new_leads = `
|
|
25828
|
+
## WHAT LEADBAY SHOULD REMEMBER
|
|
25829
|
+
|
|
25830
|
+
You keep your own memory of how this user likes to work \u2014 tone, naming, formatting, what they ask you to skip. Leadbay does not store that and does not need to.
|
|
25831
|
+
|
|
25832
|
+
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike rather than noting it.
|
|
25833
|
+
|
|
25834
|
+
|
|
25835
|
+
IRON LAW \u2014 NO FABRICATION. Every lead id, contact email, custom field id, mapping decision, and tool argument must trace to a value you read from the file the user attached or to an output from a leadbay_* tool call in this session. Do not invent values. Do not "fill in" a missing leadId with a name match. Do not synthesize a CRM id from a guess. If a value is missing, leave the field blank and say so.
|
|
25836
|
+
|
|
25837
|
+
|
|
25838
|
+
GATE \u2014 DEFER TO TOOL RENDERING. When you call a Leadbay composite that ships its own RENDERING block (every composite in 0.9.0+ does), render the response using that block's recipe verbatim \u2014 score bars, glyph palette, column order, hide-list, link priorities, all of it. Do NOT substitute prose, a numbered list, or a different column structure even when an orchestrating prompt's body suggests alternate framing. Prompt-specific commentary (motivational nudges, summaries, next-action recommendations) belongs ABOVE or BELOW the canonical table, never in place of it.
|
|
25839
|
+
|
|
25840
|
+
If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
|
|
25841
|
+
|
|
25842
|
+
|
|
25843
|
+
**First, check \`leadbay_find_new_leads\` is in your tool set.** Every phase below
|
|
25844
|
+
calls it or \`leadbay_qualify_leads\`, and both are write-tier: on a read-only
|
|
25845
|
+
deployment (\`LEADBAY_MCP_WRITE=0\`) neither is registered. The MCP prompt hides
|
|
25846
|
+
itself there; this file is a static Claude skill with no runtime gate, so the
|
|
25847
|
+
check has to be here. If they're missing: say plainly that net-new search isn't
|
|
25848
|
+
enabled on this connection, and offer \`leadbay_pull_leads\` for today's batch
|
|
25849
|
+
instead. Starting a workflow whose first call does not exist is worse than
|
|
25850
|
+
saying so up front.
|
|
25851
|
+
|
|
25852
|
+
Find net-new leads for me. My need, in my words:
|
|
25853
|
+
|
|
25854
|
+
> {{arg:need}}
|
|
25855
|
+
|
|
25856
|
+
If that need was not supplied to you directly, take it from the message that
|
|
25857
|
+
started this \u2014 the request in my own words is the need, and I should never be
|
|
25858
|
+
asked to repeat something I already said. Only when BOTH are missing or too
|
|
25859
|
+
vague to name (a) who I sell to and (b) roughly how many leads I want, ask me
|
|
25860
|
+
ONCE \u2014 one short question \u2014 then proceed. Default count when unstated: 10.
|
|
25861
|
+
|
|
25862
|
+
# PHASE 1 \u2014 UNDERSTAND THE BUYER (no tool calls yet)
|
|
25863
|
+
|
|
25864
|
+
From my words, work out:
|
|
25865
|
+
- What I SELL and therefore WHO WRITES ME CHECKS \u2014 the buyer category, never
|
|
25866
|
+
the buyer's customers, never my competitors. If my product helps companies
|
|
25867
|
+
of type X serve audience Y, my buyer is X.
|
|
25868
|
+
- Hard constraints: geography, size band, sector, exclusions ("no
|
|
25869
|
+
franchises", "pas de grands groupes" \u2014 negatives BIND).
|
|
25870
|
+
- Contact needs: do I want a person? Which titles? Email, phone, both?
|
|
25871
|
+
- Buyer archetypes: if my need genuinely spans two different kinds of buyer,
|
|
25872
|
+
plan one search per archetype \u2014 never one blended seed.
|
|
25873
|
+
|
|
25874
|
+
# PHASE 2 \u2014 CRAFT THE SEED
|
|
25875
|
+
|
|
25876
|
+
Compose the \`example_lead\` for each archetype following the craft rules in
|
|
25877
|
+
the leadbay_find_new_leads description (registry-style description of a
|
|
25878
|
+
FICTIONAL typical buyer; no invented brand name; no event language; hard
|
|
25879
|
+
constraints go in \`filters\` with the FLAT keys \`employees_min\`/\`employees_max\`
|
|
25880
|
+
and city/state/region \`locations\` \u2014 never a country name). Show me the seed
|
|
25881
|
+
description(s) in one line each \u2014 I should recognize my ideal customer in
|
|
25882
|
+
them.
|
|
25883
|
+
|
|
25884
|
+
\`filters\` only encodes sectors, locations and employee bounds. Any constraint
|
|
25885
|
+
that does not fit those keys \u2014 above all EXCLUSIONS like "no franchises" or
|
|
25886
|
+
"pas de grands groupes" \u2014 has nowhere to live in the filter schema, so it must
|
|
25887
|
+
not be dropped on the floor: express it positively in the seed \`description\`
|
|
25888
|
+
(an independent single-site operator rather than "no franchises"), and carry
|
|
25889
|
+
the exclusion forward yourself to Phase 5, where you drop violating rows and
|
|
25890
|
+
say you dropped them. Tell me plainly if a constraint can only be enforced
|
|
25891
|
+
that way \u2014 after the fact, not by the search.
|
|
25892
|
+
|
|
25893
|
+
Composing this fictional seed from my words is expected and permitted: it is
|
|
25894
|
+
the tool's designed input, not fabricated data. What must never be invented is
|
|
25895
|
+
a RESULT \u2014 company names, contacts, scores, or anything presented as coming
|
|
25896
|
+
back from Leadbay.
|
|
25897
|
+
|
|
25898
|
+
# PHASE 3 \u2014 FREE PREVIEW
|
|
25899
|
+
|
|
25900
|
+
Call \`leadbay_find_new_leads\` with the seed, \`filters\`, \`count\`,
|
|
25901
|
+
\`qualify: false\`, no channels \u2014 this is FREE \u2014 and a \`request_id\` derived
|
|
25902
|
+
from the ask + the ARCHETYPE + today's date. \`count\` is the TOTAL I asked
|
|
25903
|
+
for, not a per-search number: with two archetypes and a request for 10,
|
|
25904
|
+
split it (5 + 5, or whatever weighting fits my ask) rather than sending 10
|
|
25905
|
+
to each \u2014 otherwise I get 20 leads and, on the paid pass, pay for 20.
|
|
25906
|
+
|
|
25907
|
+
When you RETRY a search \u2014 it timed out, or the job is still live \u2014 reuse the
|
|
25908
|
+
\`request_id\` you already sent, verbatim. Do not recompute it: rederiving from
|
|
25909
|
+
"today's date" after midnight yields a new key, the backend cannot dedupe, and
|
|
25910
|
+
a second paid, novelty-claiming search launches. Roll the date only when I am
|
|
25911
|
+
genuinely asking for a new batch. The archetype component is not
|
|
25912
|
+
optional: \`request_id\` is the idempotency key, so two archetype searches
|
|
25913
|
+
sharing one id dedupe to the same job and the second archetype is never
|
|
25914
|
+
searched. Render the delivery table and judge fit honestly: are these the
|
|
25915
|
+
kind of companies I asked for?
|
|
25916
|
+
|
|
25917
|
+
- **\`still_running: true\`** \u2192 the job is ALIVE. Do not judge the seed and do
|
|
25918
|
+
not relaunch \u2014 poll \`leadbay_lead_job_status\` (\`wait_seconds: 60\`) until
|
|
25919
|
+
it goes terminal, reporting progress. Relaunching now burns an active-job
|
|
25920
|
+
slot and rate-limit budget on a search that may be about to deliver.
|
|
25921
|
+
- **On-profile** (terminal) \u2192 offer Phase 4.
|
|
25922
|
+
- **Off-profile or empty** (terminal) \u2192 read \`funnel\` +
|
|
25923
|
+
\`explain.scope_notes\`, tell me what went wrong in one line (wrong
|
|
25924
|
+
archetype? too narrow a filter? thin universe?), reshape the seed or
|
|
25925
|
+
filters, and retry under a NEW request_id. Reshaping is free; do not pay
|
|
25926
|
+
to explore a bad seed.
|
|
25927
|
+
|
|
25928
|
+
# PHASE 4 \u2014 PAID DEPTH (only with my explicit go-ahead)
|
|
25929
|
+
|
|
25930
|
+
When I want qualification evidence and/or reachable contacts:
|
|
25931
|
+
1. Quote first: \`dry_run: true\` on the tool you will actually run, with the
|
|
25932
|
+
exact flags I asked for, and tell me the worst-case cost in plain money.
|
|
25933
|
+
The two tools take DIFFERENT flags \u2014 passing the wrong one is rejected
|
|
25934
|
+
outright (\`additionalProperties: false\`):
|
|
25935
|
+
- \`leadbay_qualify_leads\`: \`qualify: true\`, \`contact_titles\`,
|
|
25936
|
+
\`title_gate\`, \`channels\`, \`max_cost\`. **No \`min_ai_score\`.**
|
|
25937
|
+
- \`leadbay_find_new_leads\`: the same, PLUS \`min_ai_score\` and \`count\`.
|
|
25938
|
+
2. On my go-ahead, prefer feeding the free preview's deliveries to
|
|
25939
|
+
\`leadbay_qualify_leads\` (\`prior_deliveries: {job_id}\`) \u2014 one paid pass PER
|
|
25940
|
+
preview job when Phase 3 ran several archetypes, or merge their delivered
|
|
25941
|
+
refs into a single \`lead_refs\` call. Never qualify just the first job and
|
|
25942
|
+
call it done: the other archetypes are part of what I asked for. It only
|
|
25943
|
+
spends on
|
|
25944
|
+
companies already known to match. Paid calls need \`confirm: true\`; without
|
|
25945
|
+
it the tool withholds the submit and hands back a quote instead of
|
|
25946
|
+
spending. That applies to \`leadbay_find_new_leads\` too whenever you set
|
|
25947
|
+
\`qualify: true\` or ask for channels.
|
|
25948
|
+
|
|
25949
|
+
If the preview delivered FEWER than I asked for, do both halves and do not
|
|
25950
|
+
conflate them: qualify what the preview already found, and run the fresh
|
|
25951
|
+
search only for the SHORTFALL \u2014 \`count\` = what is still missing, never the
|
|
25952
|
+
original number, under a NEW \`request_id\`. Reusing the preview's id dedupes
|
|
25953
|
+
the paid submit back into the free job; keeping the original count buys a
|
|
25954
|
+
whole second batch, because \`novelty: org\` already excludes everything the
|
|
25955
|
+
preview delivered.
|
|
25956
|
+
|
|
25957
|
+
The same arithmetic applies AFTER the paid pass. A full-count preview can
|
|
25958
|
+
still end short once qualification disqualifies rows or a strict title /
|
|
25959
|
+
channel match misses: what I asked for is n QUALIFIED, CONTACTABLE leads,
|
|
25960
|
+
not n examined. Count the delivered-and-callable rows; if they fall short,
|
|
25961
|
+
tell me the gap in one line and offer to top it up \u2014 another shortfall-sized
|
|
25962
|
+
search under a NEW \`request_id\`, quoted first like any paid run. Never
|
|
25963
|
+
silently hand back fewer than I asked for and paid toward.
|
|
25964
|
+
|
|
25965
|
+
Pass the leads already EXAMINED-AND-REJECTED into that top-up's
|
|
25966
|
+
\`exclude_lead_ids\` \u2014 disqualified and skipped, from both the preview and
|
|
25967
|
+
the paid pass. \`novelty: org\` already excludes prior DELIVERIES, so
|
|
25968
|
+
delivered ids are redundant there; the rejected ones are exactly what it
|
|
25969
|
+
misses, and without them the top-up re-picks the same misses and charges
|
|
25970
|
+
again to close no gap. **\`exclude_lead_ids\` caps at 500** \u2014 a wide
|
|
25971
|
+
\`exploration_cap\` can examine more than that, so send the most recent 500
|
|
25972
|
+
rejects rather than an over-long list the tool refuses outright.
|
|
25973
|
+
3. While the job runs, poll with \`leadbay_lead_job_status\`
|
|
25974
|
+
(\`wait_seconds: 60\`); report progress, not silence.
|
|
25975
|
+
|
|
25976
|
+
# PHASE 5 \u2014 DELIVER
|
|
25977
|
+
|
|
25978
|
+
Before rendering, sanity-check every row: geography inside my fence (drop
|
|
25979
|
+
and call out same-named-city leaks), descriptions actually matching my ask
|
|
25980
|
+
(especially when \`explain.seed_strategy\` is \`text_match_exemplars\` \u2014 fit
|
|
25981
|
+
scores run hot there), visible violations of my exclusions dropped. If the
|
|
25982
|
+
best fit is under 30, say "weak matches only" and propose reshaping before
|
|
25983
|
+
showing more than 3.
|
|
25984
|
+
|
|
25985
|
+
Render per the lead-delivery table, then ALWAYS the funnel line: matched /
|
|
25986
|
+
examined / qualified / disqualified / delivered / stop reason / spend. Zero
|
|
25987
|
+
delivered gets a diagnosis and a concrete next move, never a shrug. Close
|
|
25988
|
+
with NEXT STEPS from the tool description \u2014 and STOP; take no further action
|
|
25989
|
+
without my say-so.
|
|
25990
|
+
`;
|
|
23467
25991
|
var leadbay_plan_tour_in_city = `
|
|
23468
25992
|
Plan a field sales tour for me in **{{arg:city}}**{{arg:date_paren}}.
|
|
23469
25993
|
|
|
@@ -24857,6 +27381,14 @@ that's leadbay_prospecting_overview.
|
|
|
24857
27381
|
`, "arguments": [], "expected_calls": ["leadbay_account_status", "leadbay_pull_leads", "leadbay_prepare_outreach", "leadbay_enrich_titles", "leadbay_bulk_enrich_status"], "failure_modes": ['Presents a gate as prose ("let me know if you want me to pull your leads") instead of CALLING the host choice widget \u2014 the click IS the lesson, and prose turns the walkthrough into a lecture', "Runs a step's tool WITHOUT firing that step's widget first and waiting for the click \u2014 the walkthrough becomes an automated demo the user only watches, which is the exact opposite of learning by doing", "Fires the widget without the EXPLAIN beat, so the user gets an unexplained button and learns nothing about what a lens or an enrichment actually is", 'Answers gate 1 with a bare "you\'re connected as X at Y" when the quota IS readable \u2014 the user clicked a button labelled `check my account status`, so the quota windows (Daily/Weekly/Monthly gauges, % used, $ spent, resets) ARE the answer, not an optional extra', 'Renders quota as raw "credits" instead of the web app\'s percentage + dollar-spend gauges, or dumps raw `resource_type` strings the user has never seen', "Opens with a wall of text \u2014 previewing all four steps, explaining lenses up front, or writing several paragraphs before the first widget. The opening is TWO lines then the button; a first-run user wants to see it work, not read a syllabus", `Ends the first message without firing gate 1's widget, leaving the user to reply "ok" before anything happens`, "Rewrites the gate's own `next_steps` payload (its `question`, `label` or `description`) instead of mapping it into the widget verbatim, or merges two gates into a single multi-option widget", 'Fires a THIRD option, or turns the exit into an alternative route ("show me my lenses instead") \u2014 each gate carries exactly one forward action plus the `I\'m done for now` exit, never a menu of paths', `Fires a single-option widget \u2014 the host requires 2\u20134 options, so a lone option is rejected or silently degrades to prose ("say the word and I'll check it"), which is the exact defect this rule exists to prevent`, 'Launches the PAID reveal at gate 4 BEFORE the user has picked leads and confirmed \u2014 beat 1 must be the free `mode:"discover"` preview (no `titles`, no `confirm`, no `email`, no `phone`); the gate click bought the free look, not the reveal, and silence is never consent', "Stops at the free preview after the user DID pick leads and confirm \u2014 they asked for real contact details, so the second call must actually run with `confirm:true` and the chosen titles", "Reports the enrichment without polling `leadbay_bulk_enrich_status` to completion, so it claims contacts it never actually saw resolve", `Reveals contacts and never says what it cost \u2014 the user just spent credits and deserves the one-line "N contacts = N credits", which is also what makes gate 1's quota numbers concrete`, 'Reports "no leads" on an empty batch while `computing_wishlist` / `computing_scores` is true \u2014 the lens is still building; render the tool\'s own two-option warm-up widget verbatim and pause', "Rewords, reorders or prose-ifies the `next_steps` payload from `leadbay_pull_leads` instead of mapping `options[]` into the widget verbatim", "Runs all four steps in one turn without waiting for the user's click between gates \u2014 the walkthrough is a sequence of gates, not a script to recite", "Skips `leadbay_pull_leads` and jumps straight to enrichment, leaving gate 4 with no `leadIds` to scope", "Passes a singular `leadId` to `leadbay_enrich_titles` on the confirmed reveal \u2014 that key does not exist on this tool, so it is dropped and the paid call falls back to the whole default wishlist selection, charging for far more than the one lead the user agreed to. it is always the `leadIds` ARRAY, even for a single lead", "Drops the pinned `lens.id` between gates, so gate 4 enriches against a different lens than the one the user just saw", "Ends the completed walkthrough without the `keep_going` cheat-sheet \u2014 the buttons disappear with the tour, so a user who was never told what to TYPE learned to click a tutorial and nothing about using Leadbay tomorrow", "Invents phrases for the cheat-sheet, or rewords them into something that sounds nicer but doesn't match the tool's real triggers \u2014 teaching a phrase that doesn't route is worse than teaching none", "SENDS the gate 3 draft, or offers to send it \u2014 the walkthrough drafts and stops there; the email is the user's to judge, and nothing leaves the chat", "Passes `enrich:true` to `leadbay_prepare_outreach` at gate 3 \u2014 that launches a PAID contact reveal off the back of a DRAFT click, spending credits the user never agreed to", "Invents a contact NAME for the gate 3 draft \u2014 `recommended_contact` still has null email/name at that point, so the draft is addressed to the job TITLE; a fabricated name is the one thing that makes the whole draft untrustworthy", "Treats the null email at gate 3 as a failure \u2014 apologising for it, retrying, or calling another tool to fill it in. It is the setup for gate 4 \u2014 an email written, nobody to send it to yet", "Pastes the drafted email into chat prose alongside `message_compose_v1` instead of letting the composer BE the answer", "Enriches leads other than the one it drafted for at gate 3 \u2014 gate 4 reveals the person that email is going to, so it is scoped to that ONE lead, one contact, one credit", "Renders the cheat-sheet on the exit and stops there, dropping the 1:1 offer \u2014 the observed failure is that the agent feels finished once the table is on screen, so the user who just stepped out never hears about the help that would bring them back. ENDING B is not complete without the offer, and the offer goes LAST", "Treats the exit click as ENDING C (typed off-script) and closes in silence, or treats a typed request as ENDING B and buries their real answer under a cheat-sheet and a booking link", "Turns the exit offer into a pitch \u2014 several sentences, a re-opened gate, or an argument for finishing the tour. They said they were done; it is one line and a link", "Fires the 1:1 offer mid-tour, or at a user who left by TYPING a different request \u2014 a booking link on top of their real question is an interruption, not an offer", "Runs the four gates at a user whose actual problem is SETUP \u2014 the connector isn't installed, they can't sign in, or their Leadbay tools aren't appearing. The tour assumes a working connection and cannot fix any of it; the setup guide can", "Pastes the setup-guide link mid-tour, between gates, instead of once at the closing \u2014 a link in the middle of the walkthrough invites the user to leave the thing they're doing"] },
|
|
24858
27382
|
leadbay_import_file: { "name": "leadbay_import_file", "short_description": "Import a user-supplied CSV/file into Leadbay through five phases with\nevidence gates \u2014 scan, derive, resolve identities, preserve & commit,\nthen optionally qualify and report. The job is to maximize how many\nrows the Leadbay system actually ingests and matches.\n", "arguments": [{ "name": "file", "description": "Path or user-visible name of the CSV/file to import. If omitted, use the file the user attached or referenced.", "required": false }, { "name": "instruction", "description": 'Additional user goal, e.g. "then qualify the leads", "preserve owner phone as a custom field", or "only import restaurants in Manhattan".', "required": false }], "expected_calls": ["leadbay_resolve_import_rows", "leadbay_list_mappable_fields", "leadbay_create_custom_field", "leadbay_import_leads", "leadbay_import_and_qualify", "leadbay_add_note", "leadbay_import_status"], "failure_modes": ["Picks LEADBAY_ID from score alone, name-only, fuzzy-name-only, root-domain-only, brand-only, postcode-only, or city-only evidence", "Drops meaningful business notes or CRM record links instead of preserving them as custom fields or lead notes", "Treats a consumer mailbox domain (gmail.com, hotmail.com, ...) as the company domain", "Skips deriving company_domain from a business email when no website column exists (this kills match rate)", "Skips the COLUMN PRESERVATION PLAN byproduct before importing", "Skips the DECISION LOG byproduct before writing LEADBAY_ID", "Returns the imported records WITHOUT writing LEADBAY_ID values back into the user's file (leaves the user no audit trail of what matched)", "Fabricates leadIds, contact emails, or mapping IDs not present in the file or a tool response"] },
|
|
24859
27383
|
leadbay_log_outreach: { "name": "leadbay_log_outreach", "short_description": "Log outreach (an email I sent, a call I made, a meeting I had) on a\nspecific lead. Captures verification so the SDR pipeline trusts the entry.\n", "arguments": [{ "name": "lead_id", "description": "The lead UUID. Get it from leadbay_pull_leads or leadbay_research_lead_by_id.", "required": true }, { "name": "summary", "description": "1-2 sentences describing what I did (e.g. 'Sent intro email to CTO citing recent Hornsea contract').", "required": true }], "expected_calls": ["leadbay_report_outreach"], "failure_modes": ["Calls leadbay_report_outreach without first collecting a verification source", "Fabricates a gmail_message_id or calendar_event_id (the human team treats verification as canonical)", "Records outreach to a different lead_id than the one the user supplied", "Skips the dry_run step when the user is unsure what would be sent"] },
|
|
27384
|
+
leadbay_new_leads: { "name": "leadbay_new_leads", "short_description": `Guided net-new lead delivery \u2014 turn a described need ("gyms around Dallas
|
|
27385
|
+
that would buy our flooring") into ICP-perfect NEW companies with
|
|
27386
|
+
qualification evidence and the right contact, via leadbay_find_new_leads.
|
|
27387
|
+
Trigger when the user DESCRIBES who they want: "get me N companies that
|
|
27388
|
+
<profile>", "we're entering <market>". A bare "find me new leads" with no
|
|
27389
|
+
profile, and "today's leads", are the daily lens batch \u2014 leadbay_pull_leads.
|
|
27390
|
+
"Qualify these companies I have" is leadbay_qualify_leads.
|
|
27391
|
+
`, "arguments": [{ "name": "need", "description": "What the user is looking for, in their own words (e.g. '10 gyms around Dallas that would buy modular flooring, with phone numbers'). Optional \u2014 the session starts by asking when absent.", "required": false }], "expected_calls": ["leadbay_find_new_leads", "leadbay_lead_job_status", "leadbay_qualify_leads"], "failure_modes": ["Passes the user's raw sentence as `query` instead of crafting an example_lead description (vendor-vocabulary trap \u2014 measured 0 delivered from a raw query vs on-profile results from a crafted example)", "Invents a distinctive brand name in example_lead.name (pulls matching toward name-lookalikes)", 'Puts event language ("hiring", "expanding", "just raised") into the seed description', "Launches qualify:true or channels without a dry_run quote and the user's explicit go-ahead", "Retries a failed/timed-out submit with a NEW request_id (double-spend) \u2014 the same ask must reuse the same request_id", 'Reports "no results" without narrating the funnel + scope_notes and proposing a concrete fix', "Renders delivered leads as freeform prose instead of the canonical lead-delivery table", "Blends two distinct buyer archetypes into one seed description instead of running one search per archetype", "Passes a country name in filters.locations (silently matches a same-named town \u2014 whole-country intent means OMITTING locations) or a nested employees object instead of the flat employees_min/employees_max", "Renders rows that visibly violate the user's exclusions, or presents a best-fit-under-30 table as an answer instead of flagging weak matches"] },
|
|
24860
27392
|
leadbay_plan_tour_in_city: { "name": "leadbay_plan_tour_in_city", "short_description": 'Use whenever the user names a city they\'ll be in and asks who to see\n\u2014 "I\'m in SF next Tuesday, who\'s worth meeting?", "I\'m going to Berlin\n\u2014 who should I visit?", "plan my <city> tour". Any in-person/visit\nintent tied to a place routes here, NOT to `leadbay_pull_leads`. It\nsurfaces follow-ups + fresh Discover leads in the city via\n`leadbay_tour_plan`, ALWAYS offers to plot them on a map (rendering it\non yes), then offers outreach drafts + campaign persistence.\n', "arguments": [{ "name": "city", "description": "City or region the user is visiting (e.g. 'Limoges', 'Bay Area'). Used as the geo filter for both Monitor and Discover lookups. A country is not a city: this workspace already covers exactly one country, and a country name here silently fences the tour to a same-named village. Do NOT omit the argument to recover \u2014 a city-less tour returns arbitrary leads from across the whole workspace, which is not an itinerary. Ask which city or region the visit is to.", "required": true }, { "name": "date", "description": "When the visit is (e.g. 'May 24', 'next Thursday'). Surfaced in the outreach drafts as 'I'll be in <city> on <date>'.", "required": false }], "expected_calls": ["leadbay_tour_plan", "leadbay_research_lead_by_id", "leadbay_prepare_outreach", "leadbay_create_campaign"], "failure_modes": ["Calls leadbay_followups_map (Monitor-only) instead of leadbay_tour_plan \u2014 loses the Discover (fresh-lead) half that the user explicitly asked for", "Calls leadbay_pull_leads then drops the geo filter \u2014 returns the lens-wide wishlist instead of city-relevant fresh leads", 'Skips the campaign-persist step ("would you like to save these as a tour?") \u2014 leaves the rep with a one-shot map but no follow-up artifact', "Creates a campaign WITHOUT asking the user first \u2014 the persist step is high-intent; offer it, don't assume", "Fabricates lead_ids when seeding the campaign instead of using the ids returned by tour_plan"] },
|
|
24861
27393
|
leadbay_prospecting_overview: { "name": "leadbay_prospecting_overview", "short_description": `Orientation for working with Leadbay from any host \u2014 discovery vs.
|
|
24862
27394
|
follow-up, the outreach loop, outcome recording, imports, pushback /
|
|
@@ -24881,6 +27413,7 @@ var PROMPT_CATALOG_BULLETS = {
|
|
|
24881
27413
|
leadbay_getting_started: `- \`leadbay_getting_started\`: Guided first-run walkthrough \u2014 four clicks that actually use Leadbay: check the account, pull today's leads, draft a first email to the top one, then reveal who to send it to. Use when the user is new or asks to be SHOWN how Leadbay works ("walk me through Leadbay", "I'm new", "how do I use this", "give me a tour"). Don't use it for orientation prose with no clicking \u2014 that's leadbay_prospecting_overview.`,
|
|
24882
27414
|
leadbay_import_file: `- \`leadbay_import_file\` (optional args: file, instruction): Import a user-supplied CSV/file into Leadbay through five phases with evidence gates \u2014 scan, derive, resolve identities, preserve & commit, then optionally qualify and report. The job is to maximize how many rows the Leadbay system actually ingests and matches.`,
|
|
24883
27415
|
leadbay_log_outreach: `- \`leadbay_log_outreach\` (required args: lead_id, summary): Log outreach (an email I sent, a call I made, a meeting I had) on a specific lead. Captures verification so the SDR pipeline trusts the entry.`,
|
|
27416
|
+
leadbay_new_leads: `- \`leadbay_new_leads\` (optional args: need): Guided net-new lead delivery \u2014 turn a described need ("gyms around Dallas that would buy our flooring") into ICP-perfect NEW companies with qualification evidence and the right contact, via leadbay_find_new_leads. Trigger when the user DESCRIBES who they want: "get me N companies that <profile>", "we're entering <market>". A bare "find me new leads" with no profile, and "today's leads", are the daily lens batch \u2014 leadbay_pull_leads. "Qualify these companies I have" is leadbay_qualify_leads.`,
|
|
24884
27417
|
leadbay_plan_tour_in_city: `- \`leadbay_plan_tour_in_city\` (required args: city; optional args: date): Use whenever the user names a city they'll be in and asks who to see \u2014 "I'm in SF next Tuesday, who's worth meeting?", "I'm going to Berlin \u2014 who should I visit?", "plan my <city> tour". Any in-person/visit intent tied to a place routes here, NOT to \`leadbay_pull_leads\`. It surfaces follow-ups + fresh Discover leads in the city via \`leadbay_tour_plan\`, ALWAYS offers to plot them on a map (rendering it on yes), then offers outreach drafts + campaign persistence.`,
|
|
24885
27418
|
leadbay_prospecting_overview: `- \`leadbay_prospecting_overview\`: Orientation for working with Leadbay from any host \u2014 discovery vs. follow-up, the outreach loop, outcome recording, imports, pushback / snooze, and the connected-outreach-tool registry. Trigger when the conversation involves Leadbay leads, prospecting, pipeline, follow-up, outreach, or lens / ICP \u2014 anything from "show me my leads" to "what should I follow up on" to "I'll send via lemlist".`,
|
|
24886
27419
|
leadbay_qualify_top_n: `- \`leadbay_qualify_top_n\` (optional args: count): Bulk-qualify the top N un-qualified leads in the active lens. Uses leadbay_bulk_qualify_leads with a sensible default budget.`,
|
|
@@ -24892,8 +27425,8 @@ var PROMPT_CATALOG_BULLETS = {
|
|
|
24892
27425
|
};
|
|
24893
27426
|
|
|
24894
27427
|
// src/prompts.ts
|
|
24895
|
-
function userMessage(
|
|
24896
|
-
return { role: "user", content: { type: "text", text } };
|
|
27428
|
+
function userMessage(text2) {
|
|
27429
|
+
return { role: "user", content: { type: "text", text: text2 } };
|
|
24897
27430
|
}
|
|
24898
27431
|
function substitutePlaceholders(body, substitutions) {
|
|
24899
27432
|
let out = body;
|
|
@@ -24920,6 +27453,18 @@ var CATALOG = [
|
|
|
24920
27453
|
arguments: promptArguments("leadbay_prospecting_overview"),
|
|
24921
27454
|
render: () => [userMessage(leadbay_prospecting_overview)]
|
|
24922
27455
|
},
|
|
27456
|
+
{
|
|
27457
|
+
name: "leadbay_new_leads",
|
|
27458
|
+
description: PROMPT_META.leadbay_new_leads.short_description,
|
|
27459
|
+
arguments: promptArguments("leadbay_new_leads"),
|
|
27460
|
+
render: (args) => [
|
|
27461
|
+
userMessage(
|
|
27462
|
+
substitutePlaceholders(leadbay_new_leads, {
|
|
27463
|
+
need: args.need ?? "(not provided \u2014 ask me first)"
|
|
27464
|
+
})
|
|
27465
|
+
)
|
|
27466
|
+
]
|
|
27467
|
+
},
|
|
24923
27468
|
{
|
|
24924
27469
|
name: "leadbay_research_a_domain",
|
|
24925
27470
|
description: PROMPT_META.leadbay_research_a_domain.short_description,
|
|
@@ -25078,18 +27623,38 @@ var CATALOG = [
|
|
|
25078
27623
|
render: () => [userMessage(leadbay_getting_started2)]
|
|
25079
27624
|
}
|
|
25080
27625
|
];
|
|
25081
|
-
|
|
27626
|
+
var GATED_PROMPTS = {
|
|
27627
|
+
// Needs the write surface: every phase calls leadbay_find_new_leads /
|
|
27628
|
+
// leadbay_qualify_leads, which are write-tier, so a read-only server
|
|
27629
|
+
// (LEADBAY_MCP_WRITE=0) would offer a workflow whose tools are absent from
|
|
27630
|
+
// tools/list. The backend-rollout half of this gate is gone — the
|
|
27631
|
+
// /1.6/mcp/* routes shipped in backend v3.22.0.
|
|
27632
|
+
leadbay_new_leads: (opts) => opts.includeWrite !== false
|
|
27633
|
+
};
|
|
27634
|
+
function listAllPrompts() {
|
|
25082
27635
|
return CATALOG.map((c) => ({
|
|
25083
27636
|
name: c.name,
|
|
25084
27637
|
description: c.description,
|
|
25085
27638
|
arguments: c.arguments
|
|
25086
27639
|
}));
|
|
25087
27640
|
}
|
|
25088
|
-
function
|
|
27641
|
+
function listPrompts(opts = {}) {
|
|
27642
|
+
return listAllPrompts().filter((p) => {
|
|
27643
|
+
const gate = GATED_PROMPTS[p.name];
|
|
27644
|
+
return gate ? gate(opts) : true;
|
|
27645
|
+
});
|
|
27646
|
+
}
|
|
27647
|
+
function getPrompt(name, args = {}, opts = {}) {
|
|
25089
27648
|
const entry = CATALOG.find((c) => c.name === name);
|
|
25090
27649
|
if (!entry) {
|
|
25091
27650
|
throw new Error(`Unknown prompt: ${name}`);
|
|
25092
27651
|
}
|
|
27652
|
+
const gate = GATED_PROMPTS[name];
|
|
27653
|
+
if (gate && !gate(opts)) {
|
|
27654
|
+
throw new Error(
|
|
27655
|
+
`Prompt ${name} is not enabled in this deployment (requires the write surface \u2014 LEADBAY_MCP_WRITE must not be 0).`
|
|
27656
|
+
);
|
|
27657
|
+
}
|
|
25093
27658
|
const missing = entry.arguments.filter((a) => a.required && (args[a.name] === void 0 || args[a.name] === "")).map((a) => a.name);
|
|
25094
27659
|
if (missing.length > 0) {
|
|
25095
27660
|
throw new Error(
|
|
@@ -25978,8 +28543,22 @@ function buildProtocolPrimitivesParagraph(has) {
|
|
|
25978
28543
|
"import_and_qualify",
|
|
25979
28544
|
"enrich_titles",
|
|
25980
28545
|
"bulk_enrich_status",
|
|
25981
|
-
"qualify_status"
|
|
28546
|
+
"qualify_status",
|
|
28547
|
+
// The MCP-first delivery jobs block-poll for 45s by default and up to
|
|
28548
|
+
// 180s. Without a progressToken ctx.progress is absent, so the call looks
|
|
28549
|
+
// frozen for minutes — the exact case this paragraph exists to prevent.
|
|
28550
|
+
// `.filter(has)` keeps the iter-12 invariant: a deployment without the
|
|
28551
|
+
// delivery flag never sees them named.
|
|
28552
|
+
"find_new_leads",
|
|
28553
|
+
"qualify_leads",
|
|
28554
|
+
"lead_job_status"
|
|
25982
28555
|
].filter((n) => has(`leadbay_${n}`));
|
|
28556
|
+
const legacyRunners = longRunners.filter(
|
|
28557
|
+
(n) => !["find_new_leads", "qualify_leads", "lead_job_status"].includes(n)
|
|
28558
|
+
);
|
|
28559
|
+
const deliveryRunners = longRunners.filter(
|
|
28560
|
+
(n) => ["find_new_leads", "qualify_leads", "lead_job_status"].includes(n)
|
|
28561
|
+
);
|
|
25983
28562
|
const elicitTools = [
|
|
25984
28563
|
"refine_prompt clarifications",
|
|
25985
28564
|
"report_outreach.user_confirmed"
|
|
@@ -25998,9 +28577,20 @@ function buildProtocolPrimitivesParagraph(has) {
|
|
|
25998
28577
|
"(1) `notifications/progress` \u2014 when you pass `_meta.progressToken` on a tools/call, long-running composites stream per-unit-of-work progress (none of the long-runners are currently exposed in this configuration)."
|
|
25999
28578
|
);
|
|
26000
28579
|
}
|
|
26001
|
-
if (
|
|
28580
|
+
if (legacyRunners.length > 0 || deliveryRunners.length > 0) {
|
|
28581
|
+
const clauses = [];
|
|
28582
|
+
if (legacyRunners.length > 0) {
|
|
28583
|
+
clauses.push(
|
|
28584
|
+
"On " + legacyRunners.map((n) => `leadbay_${n}`).join(", ") + " the job itself keeps running on the backend; poll its notification_id / importIds later to pick it up."
|
|
28585
|
+
);
|
|
28586
|
+
}
|
|
28587
|
+
if (deliveryRunners.length > 0) {
|
|
28588
|
+
clauses.push(
|
|
28589
|
+
"On " + deliveryRunners.map((n) => `leadbay_${n}`).join(", ") + " the job is BACKEND-owned and likewise keeps running. Any work already paid for still completes; poll `leadbay_lead_job_status` with the `job_id` later to collect it."
|
|
28590
|
+
);
|
|
28591
|
+
}
|
|
26002
28592
|
parts.push(
|
|
26003
|
-
"(2) `notifications/cancelled` \u2014 when the user clicks Cancel in the host UI, the polling loop exits within \u22642 seconds.
|
|
28593
|
+
"(2) `notifications/cancelled` \u2014 when the user clicks Cancel in the host UI, the polling loop exits within \u22642 seconds. " + clauses.join(" ")
|
|
26004
28594
|
);
|
|
26005
28595
|
} else {
|
|
26006
28596
|
parts.push(
|
|
@@ -26207,11 +28797,16 @@ function buildServer(client, opts = {}) {
|
|
|
26207
28797
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
26208
28798
|
tools: toolsListPayload([...toolByName.values()])
|
|
26209
28799
|
}));
|
|
28800
|
+
const promptGate = { includeWrite: Boolean(opts.includeWrite) };
|
|
26210
28801
|
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
26211
|
-
prompts: listPrompts()
|
|
28802
|
+
prompts: listPrompts(promptGate)
|
|
26212
28803
|
}));
|
|
26213
28804
|
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
26214
|
-
return getPrompt(
|
|
28805
|
+
return getPrompt(
|
|
28806
|
+
req.params.name,
|
|
28807
|
+
req.params.arguments ?? {},
|
|
28808
|
+
promptGate
|
|
28809
|
+
);
|
|
26215
28810
|
});
|
|
26216
28811
|
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
26217
28812
|
resources: listResources()
|
|
@@ -26552,6 +29147,9 @@ ${url}
|
|
|
26552
29147
|
}) === true
|
|
26553
29148
|
}));
|
|
26554
29149
|
await maybeAttachUpdate(name, result);
|
|
29150
|
+
if (name === "leadbay_account_status" && result !== null && typeof result === "object" && !Array.isArray(result) && result.error !== true) {
|
|
29151
|
+
result.mcp_version = serverVersion;
|
|
29152
|
+
}
|
|
26555
29153
|
maybeAttachNotifications(result);
|
|
26556
29154
|
if (result && typeof result === "object" && result.error === true) {
|
|
26557
29155
|
const envText = formatErrorForLLM(result);
|
|
@@ -27291,8 +29889,8 @@ async function removeDxtExtension(claudeSupportDir) {
|
|
|
27291
29889
|
}
|
|
27292
29890
|
|
|
27293
29891
|
// installer/install-wizard.ts
|
|
27294
|
-
function ansi(
|
|
27295
|
-
return enabled ? `\x1B[${code}m${
|
|
29892
|
+
function ansi(text2, code, enabled) {
|
|
29893
|
+
return enabled ? `\x1B[${code}m${text2}\x1B[0m` : text2;
|
|
27296
29894
|
}
|
|
27297
29895
|
function parseInstallSelection(input, clientCount) {
|
|
27298
29896
|
const normalized = input.trim().toLowerCase();
|
|
@@ -27656,7 +30254,7 @@ async function createDefaultUpdateStateStore(opts = {}) {
|
|
|
27656
30254
|
}
|
|
27657
30255
|
|
|
27658
30256
|
// src/oauth.ts
|
|
27659
|
-
import { createHash as
|
|
30257
|
+
import { createHash as createHash5, randomBytes } from "crypto";
|
|
27660
30258
|
import { createServer } from "http";
|
|
27661
30259
|
import { request as httpsRequestRaw } from "https";
|
|
27662
30260
|
import { spawn as spawn3 } from "child_process";
|
|
@@ -27721,7 +30319,7 @@ async function inferRegionViaStargate(opts) {
|
|
|
27721
30319
|
function generatePkce() {
|
|
27722
30320
|
const verifier = base64UrlEncode(randomBytes(32));
|
|
27723
30321
|
const challenge = base64UrlEncode(
|
|
27724
|
-
|
|
30322
|
+
createHash5("sha256").update(verifier, "ascii").digest()
|
|
27725
30323
|
);
|
|
27726
30324
|
return { verifier, challenge, method: "S256" };
|
|
27727
30325
|
}
|
|
@@ -28203,7 +30801,7 @@ var OAUTH_BASE_URLS = {
|
|
|
28203
30801
|
fr: "https://staging.api.leadbay.app"
|
|
28204
30802
|
}
|
|
28205
30803
|
};
|
|
28206
|
-
var VERSION = "0.
|
|
30804
|
+
var VERSION = "0.38.0";
|
|
28207
30805
|
var HELP = `
|
|
28208
30806
|
leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
|
|
28209
30807
|
|
|
@@ -28678,8 +31276,8 @@ function parseFlag(args, name) {
|
|
|
28678
31276
|
function hasFlag(args, name) {
|
|
28679
31277
|
return args.some((a) => a === `--${name}`);
|
|
28680
31278
|
}
|
|
28681
|
-
function ansi2(
|
|
28682
|
-
return enabled ? `\x1B[${code}m${
|
|
31279
|
+
function ansi2(text2, code, enabled) {
|
|
31280
|
+
return enabled ? `\x1B[${code}m${text2}\x1B[0m` : text2;
|
|
28683
31281
|
}
|
|
28684
31282
|
function isInteractiveInstall() {
|
|
28685
31283
|
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|