@leadbay/mcp 0.38.0 → 0.38.2
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 +59 -0
- package/dist/bin.js +543 -290
- package/dist/http-server.js +535 -289
- package/dist/installer-electron.js +1 -1
- package/dist/installer-gui.js +1 -1
- package/package.json +1 -1
package/dist/http-server.js
CHANGED
|
@@ -38,7 +38,7 @@ What Leadbay does need is anything that changes **who it should find**. When the
|
|
|
38
38
|
|
|
39
39
|
Build me a Leadbay campaign from scratch{{arg:campaign_name_paren}} \u2014 a cohort of **{{arg:count_or_default}}** fully-actionable leads: each in-ICP, high \`ai_agent_lead_score\`, AND with a reachable buyer contact. {{arg:audience_block}} {{arg:job_titles_block}}
|
|
40
40
|
|
|
41
|
-
**Run this end-to-end, autonomously, without pausing.** Do NOT stop to confirm the audience, do NOT stop to confirm the enrichment
|
|
41
|
+
**Run this end-to-end, autonomously, without pausing.** Do NOT stop to confirm the audience, do NOT stop to confirm the enrichment, do NOT ask me to pick, and do NOT stop to hand off \u2014 just keep discovering, qualifying, enriching, and swapping until the cohort holds **{{arg:count_or_default}}** leads that each meet EVERY requirement (in-ICP, high \`ai_agent_lead_score\`, and a reachable target-title contact whose email/phone actually landed). The ONLY reasons to stop short: the lens genuinely can't supply that many buyer-ready in-ICP leads, or enrichment quota is exhausted (a backend 429). In those cases, finish with whatever you locked and tell me plainly how many you got and why it stopped. Enrichment consumes quota, not credits \u2014 never pre-refuse on a credit balance.
|
|
42
42
|
|
|
43
43
|
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.
|
|
44
44
|
|
|
@@ -76,7 +76,7 @@ If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retr
|
|
|
76
76
|
Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
|
|
77
77
|
\`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
|
|
78
78
|
running result, that work is queued on Leadbay and runs to completion, and the
|
|
79
|
-
quota it
|
|
79
|
+
quota it uses is already committed. A discovery, preview or \`dry_run\` result
|
|
80
80
|
launched nothing and is not covered here.
|
|
81
81
|
|
|
82
82
|
The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
|
|
@@ -84,7 +84,7 @@ waiting, never the job. \`cancelled: true\` means we stopped watching, not that
|
|
|
84
84
|
work stopped. What to do next depends on what you are holding:
|
|
85
85
|
|
|
86
86
|
- **A handle.** Poll the status tool with it, and do not launch the work that
|
|
87
|
-
handle covers a second time \u2014 that
|
|
87
|
+
handle covers a second time \u2014 that uses the quota again on the same rows.
|
|
88
88
|
\`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
|
|
89
89
|
under that name. A qualification started by \`leadbay_import_and_qualify\` has no
|
|
90
90
|
notification of its own: resume it with
|
|
@@ -98,7 +98,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
98
98
|
finished. Calling the same tool again with the same arguments will usually hand
|
|
99
99
|
back the job already launched rather than starting a second one, but that guard
|
|
100
100
|
is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
|
|
101
|
-
are about to re-run before you
|
|
101
|
+
are about to re-run before you use the user's quota on it.
|
|
102
102
|
|
|
103
103
|
|
|
104
104
|
|
|
@@ -197,7 +197,7 @@ If I named specific leads, seed with those (still apply the Phase 3 buyer-covera
|
|
|
197
197
|
|
|
198
198
|
# PHASE 3 \u2014 ENRICH THE RIGHT CONTACTS (load-bearing)
|
|
199
199
|
|
|
200
|
-
This is the phase that decides whether the campaign is worth a salesperson's time. Contacts aren't attached by default and enrichment
|
|
200
|
+
This is the phase that decides whether the campaign is worth a salesperson's time. Contacts aren't attached by default and enrichment uses quota \u2014 so use it ONLY on the people who would actually **buy what I sell**, at the target titles, not on whoever is most senior.
|
|
201
201
|
|
|
202
202
|
**Step A \u2014 settle the target titles / buyer persona.**
|
|
203
203
|
|
|
@@ -223,9 +223,9 @@ Call \`leadbay_recall_ordered_titles({leadIds, lensId})\` and \`leadbay_enrich_t
|
|
|
223
223
|
|
|
224
224
|
Tell me what you swapped in one line ("dropped Corbett + RBS \u2014 ops-only; swapped in Acme + Globex which have Sales VPs").
|
|
225
225
|
|
|
226
|
-
**Step C \u2014 enrich (NO confirm gate \u2014 just
|
|
226
|
+
**Step C \u2014 enrich (NO confirm gate \u2014 just launch).** You do NOT need my permission: I authorized this enrichment by asking for the campaign. Do NOT call \`ask_user_input_v0\`, do NOT ask "enrich these N now?", do NOT wait. State the persona + titles + "enriching {enrichable_contacts} contacts (email + phone, consumes quota)" in one line for the record, then immediately launch: \`leadbay_enrich_titles({leadIds, lensId, titles:[...chosen], email:true, phone:true})\`. Enrich up to {{arg:count_or_default}} best target-title contacts. Do NOT quote a "credits" figure or refuse on a credit balance \u2014 the only real limit is quota (a backend 429). If a 429 stops you mid-run, keep the leads already enriched, note how many landed, and continue to Phase 4 with those.
|
|
227
227
|
|
|
228
|
-
**Step D \u2014 poll + count only landed.** Poll \`leadbay_bulk_enrich_status\` until done (enrichment can take several minutes \u2014 keep polling, don't render an empty sheet prematurely).
|
|
228
|
+
**Step D \u2014 poll + count only landed.** Poll \`leadbay_bulk_enrich_status\` until done (enrichment can take several minutes \u2014 keep polling, don't render an empty sheet prematurely). A lead only counts toward the {{arg:count_or_default}} once its target-title contact actually landed (email/phone present); if some came back empty, swap + enrich replacements (loop back to Step B.5) until the cohort is genuinely {{arg:count_or_default}} deep or the lens is exhausted.
|
|
229
229
|
|
|
230
230
|
# PHASE 4 \u2014 CREATE THE CAMPAIGN
|
|
231
231
|
|
|
@@ -248,7 +248,7 @@ Building a campaign is NOT outreaching \u2014 do not send anything and do not ca
|
|
|
248
248
|
# Iron laws
|
|
249
249
|
|
|
250
250
|
- **Run to the goal, autonomously.** Keep discovering \u2192 qualifying \u2192 enriching \u2192 swapping until the cohort holds {{arg:count_or_default}} leads that are ALL in-ICP, high-score, and buyer-covered \u2014 or the lens is genuinely exhausted. Do NOT stop early, do NOT ask me to pick, do NOT hand off mid-flow.
|
|
251
|
-
- **No confirm gates. No pauses.** Do NOT confirm the audience switch, and do NOT confirm the enrichment
|
|
251
|
+
- **No confirm gates. No pauses.** Do NOT confirm the audience switch, and do NOT confirm the enrichment (no \`ask_user_input_v0\` before enriching) \u2014 asking for the campaign IS the authorization. The only acceptable stops are lens exhaustion or a backend 429.
|
|
252
252
|
- Enrichment targets MY buyer titles \u2014 the people who would actually buy what *I* sell (my given titles, or the persona derived from my product/ICP) \u2014 NOT generic seniority. For a sales/prospecting tool that means the revenue org; a Director of Operations, COO, or logistics manager is useless no matter how senior.
|
|
253
253
|
- Selection is DATA-DRIVEN (\`leadbay_recall_ordered_titles\` + \`leadbay_enrich_titles\` discovery) but FILTERED to the target titles \u2014 never blindly repeat past-enriched or suggested titles that don't match who buys my product.
|
|
254
254
|
- The FINAL cohort must be all buyer-ready: a lead counts only once its target-title contact actually landed. Drop/swap + re-enrich any lead with no reachable buyer rather than shipping it empty.
|
|
@@ -298,7 +298,7 @@ If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retr
|
|
|
298
298
|
Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
|
|
299
299
|
\`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
|
|
300
300
|
running result, that work is queued on Leadbay and runs to completion, and the
|
|
301
|
-
quota it
|
|
301
|
+
quota it uses is already committed. A discovery, preview or \`dry_run\` result
|
|
302
302
|
launched nothing and is not covered here.
|
|
303
303
|
|
|
304
304
|
The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
|
|
@@ -306,7 +306,7 @@ waiting, never the job. \`cancelled: true\` means we stopped watching, not that
|
|
|
306
306
|
work stopped. What to do next depends on what you are holding:
|
|
307
307
|
|
|
308
308
|
- **A handle.** Poll the status tool with it, and do not launch the work that
|
|
309
|
-
handle covers a second time \u2014 that
|
|
309
|
+
handle covers a second time \u2014 that uses the quota again on the same rows.
|
|
310
310
|
\`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
|
|
311
311
|
under that name. A qualification started by \`leadbay_import_and_qualify\` has no
|
|
312
312
|
notification of its own: resume it with
|
|
@@ -320,7 +320,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
320
320
|
finished. Calling the same tool again with the same arguments will usually hand
|
|
321
321
|
back the job already launched rather than starting a second one, but that guard
|
|
322
322
|
is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
|
|
323
|
-
are about to re-run before you
|
|
323
|
+
are about to re-run before you use the user's quota on it.
|
|
324
324
|
|
|
325
325
|
|
|
326
326
|
|
|
@@ -329,7 +329,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
329
329
|
If you're resuming an interrupted session (you see a previous Phase already completed in your task list, or the user says "continue" / "continue from where you left off"), do NOT restart from Phase 1. Re-read the active \`lensId\` and your last completed phase from prior context, then resume from the next phase. If you genuinely have no state, restart from Phase 1.
|
|
330
330
|
|
|
331
331
|
# PHASE 1 \u2014 STATE
|
|
332
|
-
Call \`leadbay_account_status\` to see what quota I have left and which lens is active. Note the remaining \`ai_rescore_remaining\` and \`web_fetch_remaining\` budgets \u2014 Phase 4 enrichment depends on them.
|
|
332
|
+
Call \`leadbay_account_status\` to see what quota I have left and which lens is active. Note the remaining \`ai_rescore_remaining\` and \`web_fetch_remaining\` budgets \u2014 Phase 4 enrichment depends on them. This is for your planning: don't show me the quota unless a window is exhausted.
|
|
333
333
|
|
|
334
334
|
# PHASE 2 \u2014 FRESH BATCH
|
|
335
335
|
Call \`leadbay_pull_leads\` to get today's fresh batch. Capture \`response.lens.id\` (the response nests it under \`lens\`). **Use it as an explicit \`lensId\` argument on every subsequent Leadbay call this session** \u2014 including any re-pulls, bulk qualifies, or research calls that accept it. (See Rule 1 above \u2014 a mid-session lens shift discards your top-10 work.)
|
|
@@ -560,7 +560,7 @@ If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retr
|
|
|
560
560
|
Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
|
|
561
561
|
\`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
|
|
562
562
|
running result, that work is queued on Leadbay and runs to completion, and the
|
|
563
|
-
quota it
|
|
563
|
+
quota it uses is already committed. A discovery, preview or \`dry_run\` result
|
|
564
564
|
launched nothing and is not covered here.
|
|
565
565
|
|
|
566
566
|
The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
|
|
@@ -568,7 +568,7 @@ waiting, never the job. \`cancelled: true\` means we stopped watching, not that
|
|
|
568
568
|
work stopped. What to do next depends on what you are holding:
|
|
569
569
|
|
|
570
570
|
- **A handle.** Poll the status tool with it, and do not launch the work that
|
|
571
|
-
handle covers a second time \u2014 that
|
|
571
|
+
handle covers a second time \u2014 that uses the quota again on the same rows.
|
|
572
572
|
\`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
|
|
573
573
|
under that name. A qualification started by \`leadbay_import_and_qualify\` has no
|
|
574
574
|
notification of its own: resume it with
|
|
@@ -582,7 +582,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
582
582
|
finished. Calling the same tool again with the same arguments will usually hand
|
|
583
583
|
back the job already launched rather than starting a second one, but that guard
|
|
584
584
|
is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
|
|
585
|
-
are about to re-run before you
|
|
585
|
+
are about to re-run before you use the user's quota on it.
|
|
586
586
|
|
|
587
587
|
|
|
588
588
|
|
|
@@ -706,10 +706,10 @@ Mirror the Leadbay web quota widget: three windows side by side \u2014 **Daily**
|
|
|
706
706
|
$ cap** figure, with a per-resource usage breakdown underneath. **Never speak in
|
|
707
707
|
raw "credits"** for quota \u2014 the unit is a percentage and a dollar spend.
|
|
708
708
|
|
|
709
|
-
**
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
709
|
+
**Show the quota only when it matters** \u2014 when the user asks about their quota,
|
|
710
|
+
usage or account status, or when a window is exhausted and blocks what they
|
|
711
|
+
asked for. A plain "what account am I connected to?" is answered with user +
|
|
712
|
+
org alone. Even then, the silence gate below comes first.
|
|
713
713
|
|
|
714
714
|
**Silence gate (check FIRST).** Render NOTHING about quota when any of these
|
|
715
715
|
holds \u2014 do not mention quota at all, do not say "unreadable", never tell the user
|
|
@@ -769,7 +769,7 @@ days"), computed against now \u2014 mirroring the widget's "r\xE9initialis\xE9 d
|
|
|
769
769
|
raw value is an ISO-8601 timestamp.
|
|
770
770
|
|
|
771
771
|
**Top-up (optional, subordinate).** When \`quota.topup\` is present, you MAY add one
|
|
772
|
-
small line below the windows: \`Top-up: $<remaining_cents/100> of $<total_credit_cents/100> left\`.
|
|
772
|
+
small line below the windows: \`Top-up: $<(remaining_cents / 100).toFixed(2)> of $<(total_credit_cents / 100).toFixed(2)> left\`.
|
|
773
773
|
Keep it secondary \u2014 the three window gauges are the headline. Omit when null.
|
|
774
774
|
|
|
775
775
|
**Legend** (once, below): \`\` \`\u25B0\` used \xB7 \`\u25B1\` remaining \`\`.
|
|
@@ -938,9 +938,9 @@ Say plainly that this only **drafts** \u2014 nothing is sent, and they see it fi
|
|
|
938
938
|
On click: call \`leadbay_prepare_outreach\` with \`leadId\` = the top lead's id,
|
|
939
939
|
**and nothing else**.
|
|
940
940
|
|
|
941
|
-
**This gate
|
|
941
|
+
**This gate uses NO quota. Never pass \`enrich: true\`** \u2014 that launches a
|
|
942
942
|
contact reveal off the back of a *draft* click. They agreed to see an email
|
|
943
|
-
written, not to
|
|
943
|
+
written, not to reveal anyone. GATE 4 is where the reveal gets asked for, on its own
|
|
944
944
|
terms.
|
|
945
945
|
|
|
946
946
|
\`recommended_contact\` comes back in its post-enrichment shape with \`email\` and
|
|
@@ -974,7 +974,7 @@ pitching whoever answers the switchboard \u2014 the difference between a
|
|
|
974
974
|
conversation and a dead end.
|
|
975
975
|
|
|
976
976
|
Say plainly that the first look is **free**, and that revealing the contact
|
|
977
|
-
|
|
977
|
+
uses a little of their plan's quota and needs their say-so.
|
|
978
978
|
|
|
979
979
|
**First, check \`leadbay_enrich_titles\` is in your tool set.** On a read-only
|
|
980
980
|
deployment it is not registered, and a gate whose tool cannot run is a dead
|
|
@@ -987,23 +987,23 @@ does nothing.
|
|
|
987
987
|
|
|
988
988
|
This gate runs in **TWO BEATS**. Do not collapse them.
|
|
989
989
|
|
|
990
|
-
## BEAT 1 \u2014 the free look (
|
|
990
|
+
## BEAT 1 \u2014 the free look (uses no quota)
|
|
991
991
|
|
|
992
992
|
On click: call \`leadbay_enrich_titles\` with \`leadIds\` = **the one lead you
|
|
993
993
|
drafted for at GATE 3** and \`lensId\` = the pinned lens id.
|
|
994
994
|
|
|
995
|
-
**This call must
|
|
995
|
+
**This call must use NO quota.** Omit \`titles\` entirely: that returns
|
|
996
996
|
\`mode:"discover"\`, the free preview of which job titles exist at that company.
|
|
997
997
|
Do NOT pass \`titles\`, \`confirm=true\`, \`email=true\` or \`phone=true\` on this call
|
|
998
|
-
\u2014 any one of them launches the
|
|
998
|
+
\u2014 any one of them launches the reveal before the user has chosen anything.
|
|
999
999
|
|
|
1000
|
-
Present the discovered titles and say plainly: "nothing
|
|
1000
|
+
Present the discovered titles and say plainly: "nothing revealed yet."
|
|
1001
1001
|
|
|
1002
|
-
## BEAT 2 \u2014 reveal the person the draft is for (
|
|
1002
|
+
## BEAT 2 \u2014 reveal the person the draft is for (uses quota)
|
|
1003
1003
|
|
|
1004
|
-
Name the title the GATE 3 draft is addressed to, and tell them
|
|
1005
|
-
|
|
1006
|
-
|
|
1004
|
+
Name the title the GATE 3 draft is addressed to, and tell them **before** they
|
|
1005
|
+
decide that revealing **one contact** uses a little of their plan's quota. No
|
|
1006
|
+
amount, no price. Then ask them to confirm.
|
|
1007
1007
|
|
|
1008
1008
|
**Wait for an explicit confirmation.** Silence is not consent, and neither is
|
|
1009
1009
|
"they clicked the gate earlier" \u2014 the gate click bought the free look, not the
|
|
@@ -1011,13 +1011,13 @@ reveal.
|
|
|
1011
1011
|
|
|
1012
1012
|
Once confirmed, call \`leadbay_enrich_titles\` AGAIN with
|
|
1013
1013
|
\`leadIds: [<the drafted lead's id>]\` \u2014 **the array, always, even for one lead**
|
|
1014
|
-
\u2014 plus the chosen \`titles\`, \`confirm: true\` and \`email: true\`. That's the real
|
|
1015
|
-
|
|
1014
|
+
\u2014 plus the chosen \`titles\`, \`confirm: true\` and \`email: true\`. That's the real
|
|
1015
|
+
reveal.
|
|
1016
1016
|
|
|
1017
1017
|
\`leadIds\` is the only key this tool reads for scope. A singular \`leadId\` is not
|
|
1018
1018
|
a parameter: it is silently ignored, and the call then falls back to the
|
|
1019
1019
|
account's **default wishlist selection** while \`confirm\`/\`email\` are set \u2014 so
|
|
1020
|
-
it would reveal
|
|
1020
|
+
it would reveal the whole batch instead of the one lead the user
|
|
1021
1021
|
agreed to.
|
|
1022
1022
|
|
|
1023
1023
|
It returns a \`notification_id\` and runs async \u2014 poll \`leadbay_bulk_enrich_status\`
|
|
@@ -1026,8 +1026,8 @@ count plateaus across a few spaced polls. Then report the contact that actually
|
|
|
1026
1026
|
resolved: name, title, and the email/phone that came back. Contacts sometimes
|
|
1027
1027
|
don't resolve; say so honestly rather than implying success.
|
|
1028
1028
|
|
|
1029
|
-
**Then close the loop** \u2014 one line:
|
|
1030
|
-
|
|
1029
|
+
**Then close the loop** \u2014 one line: the reveal used a little of their plan's
|
|
1030
|
+
quota, never a price. And say the thing that makes it land: the draft from GATE 3 now has a
|
|
1031
1031
|
real person and a real address to go to. This is the moment GATE 1's quota
|
|
1032
1032
|
numbers stop being abstract, because they just watched them move and got
|
|
1033
1033
|
something for it. Don't turn it into a pricing pitch.
|
|
@@ -1387,12 +1387,12 @@ Call \`leadbay_find_new_leads\` with the seed, \`filters\`, \`count\`,
|
|
|
1387
1387
|
from the ask + the ARCHETYPE + today's date. \`count\` is the TOTAL I asked
|
|
1388
1388
|
for, not a per-search number: with two archetypes and a request for 10,
|
|
1389
1389
|
split it (5 + 5, or whatever weighting fits my ask) rather than sending 10
|
|
1390
|
-
to each \u2014 otherwise I get 20 leads and, on the
|
|
1390
|
+
to each \u2014 otherwise I get 20 leads and, on the qualifying pass, use quota on 20.
|
|
1391
1391
|
|
|
1392
1392
|
When you RETRY a search \u2014 it timed out, or the job is still live \u2014 reuse the
|
|
1393
1393
|
\`request_id\` you already sent, verbatim. Do not recompute it: rederiving from
|
|
1394
1394
|
"today's date" after midnight yields a new key, the backend cannot dedupe, and
|
|
1395
|
-
a second
|
|
1395
|
+
a second quota-using, novelty-claiming search launches. Roll the date only when I am
|
|
1396
1396
|
genuinely asking for a new batch. The archetype component is not
|
|
1397
1397
|
optional: \`request_id\` is the idempotency key, so two archetype searches
|
|
1398
1398
|
sharing one id dedupe to the same job and the second archetype is never
|
|
@@ -1407,52 +1407,54 @@ kind of companies I asked for?
|
|
|
1407
1407
|
- **Off-profile or empty** (terminal) \u2192 read \`funnel\` +
|
|
1408
1408
|
\`explain.scope_notes\`, tell me what went wrong in one line (wrong
|
|
1409
1409
|
archetype? too narrow a filter? thin universe?), reshape the seed or
|
|
1410
|
-
filters, and retry under a NEW request_id. Reshaping is free; do not
|
|
1411
|
-
to explore a bad seed.
|
|
1410
|
+
filters, and retry under a NEW request_id. Reshaping is free; do not use
|
|
1411
|
+
quota to explore a bad seed.
|
|
1412
1412
|
|
|
1413
|
-
# PHASE 4 \u2014
|
|
1413
|
+
# PHASE 4 \u2014 QUALIFIED DEPTH (uses my plan's quota; only with my explicit go-ahead)
|
|
1414
1414
|
|
|
1415
1415
|
When I want qualification evidence and/or reachable contacts:
|
|
1416
|
-
1.
|
|
1417
|
-
exact flags I asked for
|
|
1416
|
+
1. Check first: \`dry_run: true\` on the tool you will actually run, with the
|
|
1417
|
+
exact flags I asked for. Tell me what will run (how many companies, which
|
|
1418
|
+
channels) and that it uses my plan's quota. No amounts and no money: my plan
|
|
1419
|
+
or top-up covers this, and a price reads as a bill.
|
|
1418
1420
|
The two tools take DIFFERENT flags \u2014 passing the wrong one is rejected
|
|
1419
1421
|
outright (\`additionalProperties: false\`):
|
|
1420
1422
|
- \`leadbay_qualify_leads\`: \`qualify: true\`, \`contact_titles\`,
|
|
1421
1423
|
\`title_gate\`, \`channels\`, \`max_cost\`. **No \`min_ai_score\`.**
|
|
1422
1424
|
- \`leadbay_find_new_leads\`: the same, PLUS \`min_ai_score\` and \`count\`.
|
|
1423
1425
|
2. On my go-ahead, prefer feeding the free preview's deliveries to
|
|
1424
|
-
\`leadbay_qualify_leads\` (\`prior_deliveries: {job_id}\`) \u2014 one
|
|
1426
|
+
\`leadbay_qualify_leads\` (\`prior_deliveries: {job_id}\`) \u2014 one qualifying pass PER
|
|
1425
1427
|
preview job when Phase 3 ran several archetypes, or merge their delivered
|
|
1426
1428
|
refs into a single \`lead_refs\` call. Never qualify just the first job and
|
|
1427
1429
|
call it done: the other archetypes are part of what I asked for. It only
|
|
1428
|
-
|
|
1429
|
-
companies already known to match.
|
|
1430
|
+
uses quota on
|
|
1431
|
+
companies already known to match. Calls that use quota need \`confirm: true\`; without
|
|
1430
1432
|
it the tool withholds the submit and hands back a quote instead of
|
|
1431
|
-
|
|
1433
|
+
running. That applies to \`leadbay_find_new_leads\` too whenever you set
|
|
1432
1434
|
\`qualify: true\` or ask for channels.
|
|
1433
1435
|
|
|
1434
1436
|
If the preview delivered FEWER than I asked for, do both halves and do not
|
|
1435
1437
|
conflate them: qualify what the preview already found, and run the fresh
|
|
1436
1438
|
search only for the SHORTFALL \u2014 \`count\` = what is still missing, never the
|
|
1437
1439
|
original number, under a NEW \`request_id\`. Reusing the preview's id dedupes
|
|
1438
|
-
the
|
|
1440
|
+
the qualifying submit back into the free job; keeping the original count runs a
|
|
1439
1441
|
whole second batch, because \`novelty: org\` already excludes everything the
|
|
1440
1442
|
preview delivered.
|
|
1441
1443
|
|
|
1442
|
-
The same arithmetic applies AFTER the
|
|
1444
|
+
The same arithmetic applies AFTER the qualifying pass. A full-count preview can
|
|
1443
1445
|
still end short once qualification disqualifies rows or a strict title /
|
|
1444
1446
|
channel match misses: what I asked for is n QUALIFIED, CONTACTABLE leads,
|
|
1445
1447
|
not n examined. Count the delivered-and-callable rows; if they fall short,
|
|
1446
|
-
tell me the gap in one line and offer to
|
|
1447
|
-
search under a NEW \`request_id\`,
|
|
1448
|
-
silently hand back fewer than I asked for
|
|
1448
|
+
tell me the gap in one line and offer to fill the gap \u2014 another shortfall-sized
|
|
1449
|
+
search under a NEW \`request_id\`, confirmed first like any run that uses
|
|
1450
|
+
quota. Never silently hand back fewer than I asked for.
|
|
1449
1451
|
|
|
1450
|
-
Pass the leads already EXAMINED-AND-REJECTED into that
|
|
1452
|
+
Pass the leads already EXAMINED-AND-REJECTED into that search's
|
|
1451
1453
|
\`exclude_lead_ids\` \u2014 disqualified and skipped, from both the preview and
|
|
1452
|
-
the
|
|
1454
|
+
the qualifying pass. \`novelty: org\` already excludes prior DELIVERIES, so
|
|
1453
1455
|
delivered ids are redundant there; the rejected ones are exactly what it
|
|
1454
|
-
misses, and without them
|
|
1455
|
-
again to close no gap. **\`exclude_lead_ids\` caps at 500** \u2014 a wide
|
|
1456
|
+
misses, and without them that search re-picks the same misses and uses
|
|
1457
|
+
quota again to close no gap. **\`exclude_lead_ids\` caps at 500** \u2014 a wide
|
|
1456
1458
|
\`exploration_cap\` can examine more than that, so send the most recent 500
|
|
1457
1459
|
rejects rather than an over-long list the tool refuses outright.
|
|
1458
1460
|
3. While the job runs, poll with \`leadbay_lead_job_status\`
|
|
@@ -1468,7 +1470,7 @@ best fit is under 30, say "weak matches only" and propose reshaping before
|
|
|
1468
1470
|
showing more than 3.
|
|
1469
1471
|
|
|
1470
1472
|
Render per the lead-delivery table, then ALWAYS the funnel line: matched /
|
|
1471
|
-
examined / qualified / disqualified / delivered / stop reason
|
|
1473
|
+
examined / qualified / disqualified / delivered / stop reason. Zero
|
|
1472
1474
|
delivered gets a diagnosis and a concrete next move, never a shrug. Close
|
|
1473
1475
|
with NEXT STEPS from the tool description \u2014 and STOP; take no further action
|
|
1474
1476
|
without my say-so.
|
|
@@ -1623,7 +1625,7 @@ If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retr
|
|
|
1623
1625
|
Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
|
|
1624
1626
|
\`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
|
|
1625
1627
|
running result, that work is queued on Leadbay and runs to completion, and the
|
|
1626
|
-
quota it
|
|
1628
|
+
quota it uses is already committed. A discovery, preview or \`dry_run\` result
|
|
1627
1629
|
launched nothing and is not covered here.
|
|
1628
1630
|
|
|
1629
1631
|
The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
|
|
@@ -1631,7 +1633,7 @@ waiting, never the job. \`cancelled: true\` means we stopped watching, not that
|
|
|
1631
1633
|
work stopped. What to do next depends on what you are holding:
|
|
1632
1634
|
|
|
1633
1635
|
- **A handle.** Poll the status tool with it, and do not launch the work that
|
|
1634
|
-
handle covers a second time \u2014 that
|
|
1636
|
+
handle covers a second time \u2014 that uses the quota again on the same rows.
|
|
1635
1637
|
\`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
|
|
1636
1638
|
under that name. A qualification started by \`leadbay_import_and_qualify\` has no
|
|
1637
1639
|
notification of its own: resume it with
|
|
@@ -1645,7 +1647,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
1645
1647
|
finished. Calling the same tool again with the same arguments will usually hand
|
|
1646
1648
|
back the job already launched rather than starting a second one, but that guard
|
|
1647
1649
|
is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
|
|
1648
|
-
are about to re-run before you
|
|
1650
|
+
are about to re-run before you use the user's quota on it.
|
|
1649
1651
|
|
|
1650
1652
|
|
|
1651
1653
|
|
|
@@ -1788,7 +1790,7 @@ All \`Calls\` below are agent-callable \`leadbay_*\` tools (never an MCP prompt
|
|
|
1788
1790
|
|---------------------------------------------------------------------|--------------------------------------------------------|--------------------------------------------------------------|
|
|
1789
1791
|
| Fresh discovery batch waiting / user wants new leads | "See today's best new leads" | leadbay_pull_leads(lensId = pinned) |
|
|
1790
1792
|
| Follow-ups due / known leads to re-engage | "Show follow-ups due now" | leadbay_pull_followups |
|
|
1791
|
-
| Quota/credit read shows
|
|
1793
|
+
| Quota/credit read shows exhausted balance | "Review what's eating your quota" | leadbay_account_status (deeper read) |
|
|
1792
1794
|
| Auth/connection blocker (e.g. 401 / AUTH_EXPIRED on a read) | "Reconnect Leadbay to unblock actions" | (guide the user to re-authenticate \u2014 no tool call) |
|
|
1793
1795
|
| Lens audience looks mismatched (batch is off-ICP) | "Adjust the lens audience to match your ICP" | ASK first \u2014 collect the target sectors / sizes / exclusions, THEN leadbay_adjust_audience(...) with those params. NEVER call it with no args (an empty call writes the current filter / may clone the default lens \u2014 a no-op or unwanted change). |
|
|
1794
1796
|
| Status is healthy and nothing is pending | propose nothing \u2014 the overview is a complete answer | \u2014 |
|
|
@@ -2336,7 +2338,7 @@ If \`pull_leads\` itself fails and you have no prior batch, then yes \u2014 retr
|
|
|
2336
2338
|
Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
|
|
2337
2339
|
\`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
|
|
2338
2340
|
running result, that work is queued on Leadbay and runs to completion, and the
|
|
2339
|
-
quota it
|
|
2341
|
+
quota it uses is already committed. A discovery, preview or \`dry_run\` result
|
|
2340
2342
|
launched nothing and is not covered here.
|
|
2341
2343
|
|
|
2342
2344
|
The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
|
|
@@ -2344,7 +2346,7 @@ waiting, never the job. \`cancelled: true\` means we stopped watching, not that
|
|
|
2344
2346
|
work stopped. What to do next depends on what you are holding:
|
|
2345
2347
|
|
|
2346
2348
|
- **A handle.** Poll the status tool with it, and do not launch the work that
|
|
2347
|
-
handle covers a second time \u2014 that
|
|
2349
|
+
handle covers a second time \u2014 that uses the quota again on the same rows.
|
|
2348
2350
|
\`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
|
|
2349
2351
|
under that name. A qualification started by \`leadbay_import_and_qualify\` has no
|
|
2350
2352
|
notification of its own: resume it with
|
|
@@ -2358,7 +2360,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
2358
2360
|
finished. Calling the same tool again with the same arguments will usually hand
|
|
2359
2361
|
back the job already launched rather than starting a second one, but that guard
|
|
2360
2362
|
is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
|
|
2361
|
-
are about to re-run before you
|
|
2363
|
+
are about to re-run before you use the user's quota on it.
|
|
2362
2364
|
|
|
2363
2365
|
|
|
2364
2366
|
|
|
@@ -2579,11 +2581,11 @@ Cash-to-capture is not available: it needs \`ca12\` from my invoicing system, wh
|
|
|
2579
2581
|
|
|
2580
2582
|
# PHASE 5 \u2014 CONTACTS (consent-gated)
|
|
2581
2583
|
|
|
2582
|
-
Each card needs a reachable decision-maker. \`leadbay_enrich_titles({leadIds, lensId})\` in discovery mode first \u2014 that reveals what's enrichable and
|
|
2584
|
+
Each card needs a reachable decision-maker. \`leadbay_enrich_titles({leadIds, lensId})\` in discovery mode first \u2014 that reveals what's enrichable and uses no quota. Render whatever contact detail is already on the record; many accounts already carry a named contact.
|
|
2583
2585
|
|
|
2584
|
-
**Do NOT stop and wait for enrichment consent before delivering.** Asking for a plan is not authorization to
|
|
2586
|
+
**Do NOT stop and wait for enrichment consent before delivering.** Asking for a plan is not authorization to use quota on {{arg:count_or_default}} accounts \u2014 but neither is it a reason to end the turn on a consent question with no plan attached. Ship the ranked plan (Phase 6), then **offer** the reveal alongside it. The discovery call returned no \`titles\`, so it only told you what's *available* \u2014 **the offer must therefore carry the titles you propose to enrich AND the channels**, not just a volume: "enrich N contacts at these titles (\`<the titles you picked from available_titles / title_suggestions>\`), email only / email + phone \u2014 reveals consume quota". A bare "yes" to a volume-only question is not a mandate to pick titles yourself, and re-running discovery instead of launching wastes a turn.
|
|
2585
2587
|
|
|
2586
|
-
\u26A0 **Do NOT quote a cost or a credits figure.** The per-reveal rate is backend-side and enrichment is gated by quota, not a credit balance; \`credits_remaining\` is advisory context only. A
|
|
2588
|
+
\u26A0 **Do NOT quote a cost or a credits figure.** The per-reveal rate is backend-side and enrichment is gated by quota, not a credit balance; \`credits_remaining\` is advisory context only. A usage number invented to make the offer concrete is the same failure as an invented euro on a card.
|
|
2587
2589
|
|
|
2588
2590
|
On an explicit yes, launch with the agreed \`titles\` + channels, then poll \`leadbay_bulk_enrich_status\` until done and **keep the \`notification_id\` handles** for the deck.
|
|
2589
2591
|
|
|
@@ -2731,9 +2733,9 @@ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We
|
|
|
2731
2733
|
- One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
|
|
2732
2734
|
|
|
2733
2735
|
|
|
2734
|
-
\u26A0 **The deck's contact layer depends on what actually happened in Phase 5.** Bind a \`leadbay_bulk_enrich_status\` resource ONLY if a
|
|
2736
|
+
\u26A0 **The deck's contact layer depends on what actually happened in Phase 5.** Bind a \`leadbay_bulk_enrich_status\` resource ONLY if a reveal was launched and you hold a \`notification_id\`. If the user accepted the deck but not the reveal, render the contacts already on record and carry the reveal offer inside the deck \u2014 never wire a status resource with no handle (it renders permanently empty) and never launch enrichment from the deck to manufacture one.
|
|
2735
2737
|
|
|
2736
|
-
On acceptance, call \`leadbay_artifact_kit\`, read its \`usage_guide\` before writing any code, and build a single-file deck. Wire the live layer from the handles you kept: a poll-until-done resource per \`notification_id\` for the qualification pills, and one over \`leadbay_bulk_enrich_status\` for the contacts. \u26A0 **If enrichment already ran this session, bind the existing \`notification_id\` \u2014 re-launching enrichment from the deck
|
|
2738
|
+
On acceptance, call \`leadbay_artifact_kit\`, read its \`usage_guide\` before writing any code, and build a single-file deck. Wire the live layer from the handles you kept: a poll-until-done resource per \`notification_id\` for the qualification pills, and one over \`leadbay_bulk_enrich_status\` for the contacts. \u26A0 **If enrichment already ran this session, bind the existing \`notification_id\` \u2014 re-launching enrichment from the deck uses my quota twice.** Per-card notes and outcomes go through the pre-wired note/outreach view-models (they carry the required verification and \`_triggered_by\` fields; hand-rolling those is where it breaks). Keep the checklists in local storage, and always wire a Refresh \u2014 auto-poll is host-dependent. List every tool the deck calls in its \`mcp_tools\`, and render the bridge-unavailable branch, or the pills silently show empty.
|
|
2737
2739
|
|
|
2738
2740
|
# Iron laws
|
|
2739
2741
|
|
|
@@ -2743,7 +2745,7 @@ On acceptance, call \`leadbay_artifact_kit\`, read its \`usage_guide\` before wr
|
|
|
2743
2745
|
- **Deliver first, ask alongside.** Do not end a turn without a ranked list of real accounts. The benchmark, the Tier-1 threshold, the territory, a missing lens, a short question set and an unanswered enrichment offer are all NON-blocking \u2014 carry them next to the plan. Only an unresolvable identity mismatch (whose plan is this?) may stop delivery.
|
|
2744
2746
|
- **One motif per account, from the closed set of six**, with its deciding evidence stated.
|
|
2745
2747
|
- **The org's real qualification questions**, read from Leadbay \u2014 never invented.
|
|
2746
|
-
- **Consent before any
|
|
2748
|
+
- **Consent before any enrichment**, and never re-launch a bulk that already exists.
|
|
2747
2749
|
- **Offer the deck; don't force it.** The chat answer must stand alone as useful.
|
|
2748
2750
|
- Carry the captured \`lensId\` on the calls whose schema **accepts** it (\`leadbay_pull_leads\`, \`leadbay_bulk_qualify_leads\`, \`leadbay_enrich_titles\`). Do NOT add it to \`leadbay_pull_followups\`, \`leadbay_scan_portfolio_signals\`, \`leadbay_qualify_status\` or \`leadbay_bulk_enrich_status\` \u2014 they declare no such argument and reject unknown properties.
|
|
2749
2751
|
- Building a plan is not outreaching \u2014 do not send anything and do not call \`leadbay_report_outreach\`.
|
|
@@ -2795,7 +2797,7 @@ Call \`leadbay_campaign_call_sheet({campaign_id})\`. The response carries \`summ
|
|
|
2795
2797
|
|
|
2796
2798
|
- "\u{1F4DE} Start calling now" \u2014 IF \`ready_for_calling\`. Top option when phones are there.
|
|
2797
2799
|
- "\u2709 Email session instead" \u2014 IF \`ready_for_emailing\` AND \`email_ratio > phone_ratio\`. Don't surface this when calling is more obvious.
|
|
2798
|
-
- "\u{1F527} Enrich titles first" \u2014 IF \`needs_enrichment\`. Top option when most leads have no contacts. Phrase as "<N> leads have no reachable contact yet \u2014 enrich titles before we start?" so the user
|
|
2800
|
+
- "\u{1F527} Enrich titles first" \u2014 IF \`needs_enrichment\`. Top option when most leads have no contacts. Phrase as "<N> leads have no reachable contact yet \u2014 enrich titles before we start?" so the user knows it uses quota.
|
|
2799
2801
|
- "\u{1F5FA} View on a map" \u2014 IF \`travel_friendly\` **AND** the user hasn't previously signaled disinterest in maps (check your conversation memory; if you've seen the user dismiss map renders before in this session or saved a "no maps" preference, drop this option).
|
|
2800
2802
|
|
|
2801
2803
|
If the MCP prompt argument \`mode\` was actually supplied, skip the proposal and jump to the matching mode below. If \`mode\` was omitted, do not treat \`call_sheet\` as implicit user consent \u2014 propose first.
|
|
@@ -2853,7 +2855,7 @@ Optional: offer to review the \`leadbay_campaign_progression\` for the same camp
|
|
|
2853
2855
|
- If the user dictates an outcome that doesn't cleanly map to one of the four epilogue values, ASK ONCE before guessing.
|
|
2854
2856
|
`;
|
|
2855
2857
|
var PROMPT_META = {
|
|
2856
|
-
leadbay_build_campaign: { "name": "leadbay_build_campaign", "short_description": 'Build a sales campaign from scratch, autonomously, to a target size:\ndiscover on the lens, qualify, and enrich the buyer titles until `count`\nleads each have a reachable target-title contact \u2014 no pauses, no confirm\ngates. Saves via `leadbay_create_campaign` and renders a one-tap\ncall/email view via `leadbay_campaign_call_sheet`. Trigger on "build me a\ncampaign", "build N leads", "create a campaign from scratch". Work an\nexisting one with `leadbay_work_campaign`.\n', "arguments": [{ "name": "audience", "description": "Optional: a fresh audience to target (e.g. 'dental clinics in Texas'). Omit to build from your ACTIVE lens \u2014 the default.", "required": false }, { "name": "campaign_name", "description": "Optional: a name for the campaign. Omit and one is derived from the lens/audience + date (or the backend AI-names it).", "required": false }, { "name": "count", "description": "Optional: how many fully-actionable leads to build (default 20). The loop keeps discovering, qualifying and enriching until this many in-ICP leads each have a reachable target-title contact \u2014 or the lens is exhausted. Higher counts take longer and consume more quota.", "required": false }, { "name": "job_titles", "description": "Optional: the exact buyer job titles to enrich, comma-separated (e.g. 'VP Sales, Head of Growth, Director of Business Development'). Omit and the buyer persona is derived from what you sell. A lead only counts toward the target when it has a reachable contact matching one of these titles.", "required": false }], "expected_calls": ["leadbay_account_status", "leadbay_pull_leads", "leadbay_bulk_qualify_leads", "leadbay_qualify_status", "leadbay_recall_ordered_titles", "leadbay_enrich_titles", "leadbay_bulk_enrich_status", "leadbay_create_campaign", "leadbay_add_leads_to_campaign", "leadbay_campaign_call_sheet", "leadbay_campaign_progression", "leadbay_new_lens", "leadbay_adjust_audience"], "failure_modes": ["Pauses to confirm before enriching (or asks 'enrich these N now?' via ask_user_input_v0) \u2014 this prompt runs to goal with NO confirm gate; asking for the campaign IS the authorization. Never stop for
|
|
2858
|
+
leadbay_build_campaign: { "name": "leadbay_build_campaign", "short_description": 'Build a sales campaign from scratch, autonomously, to a target size:\ndiscover on the lens, qualify, and enrich the buyer titles until `count`\nleads each have a reachable target-title contact \u2014 no pauses, no confirm\ngates. Saves via `leadbay_create_campaign` and renders a one-tap\ncall/email view via `leadbay_campaign_call_sheet`. Trigger on "build me a\ncampaign", "build N leads", "create a campaign from scratch". Work an\nexisting one with `leadbay_work_campaign`.\n', "arguments": [{ "name": "audience", "description": "Optional: a fresh audience to target (e.g. 'dental clinics in Texas'). Omit to build from your ACTIVE lens \u2014 the default.", "required": false }, { "name": "campaign_name", "description": "Optional: a name for the campaign. Omit and one is derived from the lens/audience + date (or the backend AI-names it).", "required": false }, { "name": "count", "description": "Optional: how many fully-actionable leads to build (default 20). The loop keeps discovering, qualifying and enriching until this many in-ICP leads each have a reachable target-title contact \u2014 or the lens is exhausted. Higher counts take longer and consume more quota.", "required": false }, { "name": "job_titles", "description": "Optional: the exact buyer job titles to enrich, comma-separated (e.g. 'VP Sales, Head of Growth, Director of Business Development'). Omit and the buyer persona is derived from what you sell. A lead only counts toward the target when it has a reachable contact matching one of these titles.", "required": false }], "expected_calls": ["leadbay_account_status", "leadbay_pull_leads", "leadbay_bulk_qualify_leads", "leadbay_qualify_status", "leadbay_recall_ordered_titles", "leadbay_enrich_titles", "leadbay_bulk_enrich_status", "leadbay_create_campaign", "leadbay_add_leads_to_campaign", "leadbay_campaign_call_sheet", "leadbay_campaign_progression", "leadbay_new_lens", "leadbay_adjust_audience"], "failure_modes": ["Pauses to confirm before enriching (or asks 'enrich these N now?' via ask_user_input_v0) \u2014 this prompt runs to goal with NO confirm gate; asking for the campaign IS the authorization. Never stop for an enrichment confirmation.", "Stops to confirm a lens switch when the user named a fresh audience \u2014 naming the audience IS the authorization; switch, state which lens in one line, and don't ask.", "Pauses at any point to ask the user to choose, confirm, or hand off \u2014 the only acceptable stops are lens exhaustion (can't supply the count) or a backend 429 (quota out). Anything else is purpose drift.", "Stops at fewer than the target `count` of actionable leads without looping back to pull / qualify / enrich more \u2014 must run to the target, or honestly report the lens is exhausted and offer to widen.", "Counts a lead toward `count` before its target-title contact actually landed (email/phone present) \u2014 an empty enrichment doesn't count; swap and re-enrich until the cohort is genuinely `count` deep.", "Enriches by seniority instead of by buyer persona \u2014 picks COO / Director of Operations / Mgr of Logistics / CFO / CTO because they sound senior, when the user sells a SALES tool whose buyer is the revenue org (VP/Head/Director of Sales, BD, growth, marketing). Operations people never buy a sales tool; this hands the salesperson a useless list.", "When no titles are given, fails to derive the user's buyer persona from their product/ICP before choosing titles \u2014 jumps straight to generic exec titles instead of working out who buys what THIS user sells. (When titles ARE given, use them verbatim \u2014 don't substitute 'more senior' ones.)", "Blindly repeats leadbay_recall_ordered_titles / discovery suggestions even when they are off-persona (e.g. operations roles a prior session wrongly enriched) \u2014 recall is a filtered input, not the answer.", "Poor coverage \u2014 leaves picked leads with no target-title contact (or 0 enrichments on some leads) and ships them anyway, so the salesperson opens the campaign to half-empty rows. Swap them out and refill to the count instead.", "Creates the campaign before qualifying / picking \u2014 seeds a campaign with unvetted leads. Qualify and lock the buyer-covered cohort FIRST, then leadbay_create_campaign.", "Ends at 'campaign created' without rendering the leadbay_campaign_call_sheet view \u2014 the ready-to-work view IS the deliverable; stopping short is purpose drift.", "Runs the calling / outcome / follow-up loop, or calls leadbay_work_campaign itself, instead of stopping at the call sheet \u2014 this prompt BUILDS and stops; work_campaign is a separate session the user starts later.", "Auto-sends outreach or calls leadbay_report_outreach \u2014 building a campaign is not outreaching. No send, no log.", "Re-pulls leadbay_pull_leads without the captured lensId \u2014 a mid-session lens shift discards the cohort being built.", "Renders the picked leads or the call sheet as prose instead of the canonical per-tool RENDERING layout."] },
|
|
2857
2859
|
leadbay_daily_check_in: { "name": "leadbay_daily_check_in", "short_description": 'Morning DISCOVERY workflow \u2014 new leads from the lens wishlist. Trigger\non "show me leads", "what\'s new today", "let\'s prospect", "run my check-in",\n"my morning check-in", "I do this every day", "every morning". Recurrence\nlanguage always means this prompt. Do NOT trigger on follow-up phrasings\n("follow up", "before my trip") \u2014 those go to `leadbay_followup_check_in`.\n', "arguments": [], "expected_calls": ["leadbay_account_status", "leadbay_pull_leads", "leadbay_research_lead_by_id", "leadbay_bulk_qualify_leads", "leadbay_enrich_contacts"], "failure_modes": ["Calls leadbay_report_outreach without explicit user authorization", "Surfaces fewer than 10 leads when more are available, or fails to top up via leadbay_qualify_top_n when the batch is short", `Replaces the canonical pull_leads table layout with prose per row (the per-tool RENDERING block is the structural contract; "Today's nudges" goes above it, not in place of it)`, "Skips the nudge paragraph entirely \u2014 the table alone is fine but adding the nudge is the value-add", `Skips deep research on promising leads (Phase 4) \u2014 the agent must call leadbay_research_lead_by_id on each when the user's intent is to research specific leads; Phase 4 is intentionally skipped for batch-view requests ("show me today's leads", "run my morning check-in") per the Phase 4 skip gate`, "Triggers contact enrichment without asking the user first (it consumes quota)", "Skips the STOP byproduct and proposes next actions on its own", 'Fires 10 parallel leadbay_research_lead_by_id calls and treats "stream closed" errors as terminal \u2014 must serialize and retry singletons', "Re-pulls leadbay_pull_leads without passing the captured lensId, allowing a backend lens shift to discard the Phase 2 batch", 'Treats a "Request timed out" from leadbay_bulk_qualify_leads as terminal instead of retrying with wait_for_completion:false + qualify_status polling', 'Triggers on a follow-up query (e.g., "leads I should follow up with") that should have routed to `leadbay_followup_check_in` \u2014 the two entry points are different data sources (Discover wishlist vs Monitor view) per \xA71.6'] },
|
|
2858
2860
|
leadbay_extend_my_lens: { "name": "leadbay_extend_my_lens", "short_description": "Add more leads to the current lens on demand \u2014 for users whose appetite\nexceeds the standard daily fill. The agent picks seeds silently from\nwhat's already on the lens, fires the extra refill, and surfaces the\nqueue confirmation. The user never reviews the seed list.\n", "arguments": [{ "name": "extra_count", "description": "How many extra leads to add. Optional. Omit to use the backend default.", "required": false }], "expected_calls": ["leadbay_account_status", "leadbay_seed_candidates", "leadbay_extend_lens", "leadbay_pull_leads"], "failure_modes": ["Surfaces the seed candidate list to the user instead of picking silently \u2014 the user asked for MORE LEADS, not a candidate review meeting", "Skips the seeded path and calls `leadbay_extend_lens` with no `seed_lead_ids`, losing the bias signal the recommender needs", "On 429, silently retries instead of surfacing the three options (smaller / wait / upgrade) via your host's choice widget (`ask_user_input_v0` or `AskUserQuestion`)", "Forgets to pre-check `LENS_EXTRA_REFILL` quota in `leadbay_account_status` and burns a wasted API call", "Skips the post-queue pull-leads suggestion, so the user doesn't see what just got added"] },
|
|
2859
2861
|
leadbay_followup_check_in: { "name": "leadbay_followup_check_in", "short_description": 'Follow-up check-in: surface KNOWN leads from the Monitor view needing\nre-engagement. Trigger on "follow up", "already known leads", "what\'s\noverdue", "before my trip", "who should I re-engage". Do NOT trigger on\n"show me today\'s leads", "my morning check-in", "run my check-in",\n"I do this every day", "every morning" \u2014 those go to\n`leadbay_daily_check_in`.\n', "arguments": [], "expected_calls": ["leadbay_pull_followups", "leadbay_research_lead_by_id", "leadbay_prepare_outreach"], "failure_modes": ["Calls leadbay_pull_leads (the Discover entry point) instead of leadbay_pull_followups \u2014 these are different data sources; the Discover queue does NOT contain Monitor's known-but-cold pipeline", 'Iterates pages of leadbay_pull_leads filtering by engagement_count to "fake" a follow-up view (a real bug observed in 0.9.0 \u2014 the right move is to call pull_followups directly)', "Replaces the canonical pull_followups table layout with prose per row (the per-tool RENDERING block is the structural contract; commentary belongs above or below)", 'Skips the cross-mode pivot offer at the end ("Want to see NEW leads from your wishlist instead?" routes to leadbay_pull_leads)'] },
|
|
@@ -2863,7 +2865,7 @@ reveal who to send it to. Use when the user is new or asks to be SHOWN how
|
|
|
2863
2865
|
Leadbay works ("walk me through Leadbay", "I'm new", "how do I use this",
|
|
2864
2866
|
"give me a tour"). Don't use it for orientation prose with no clicking \u2014
|
|
2865
2867
|
that's leadbay_prospecting_overview.
|
|
2866
|
-
`, "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
|
|
2868
|
+
`, "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 contact 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 the reveal used a little of their plan's quota \u2014 that one line, with no amount and no price, is 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 reveal falls back to the whole default wishlist selection, revealing 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 contact reveal off the back of a DRAFT click, using quota 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", "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"] },
|
|
2867
2869
|
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"] },
|
|
2868
2870
|
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"] },
|
|
2869
2871
|
leadbay_new_leads: { "name": "leadbay_new_leads", "short_description": `Guided net-new lead delivery \u2014 turn a described need ("gyms around Dallas
|
|
@@ -2873,7 +2875,7 @@ Trigger when the user DESCRIBES who they want: "get me N companies that
|
|
|
2873
2875
|
<profile>", "we're entering <market>". A bare "find me new leads" with no
|
|
2874
2876
|
profile, and "today's leads", are the daily lens batch \u2014 leadbay_pull_leads.
|
|
2875
2877
|
"Qualify these companies I have" is leadbay_qualify_leads.
|
|
2876
|
-
`, "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
|
|
2878
|
+
`, "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 launch) \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"] },
|
|
2877
2879
|
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"] },
|
|
2878
2880
|
leadbay_prospecting_overview: { "name": "leadbay_prospecting_overview", "short_description": `Orientation for working with Leadbay from any host \u2014 discovery vs.
|
|
2879
2881
|
follow-up, the outreach loop, outcome recording, imports, pushback /
|
|
@@ -2886,7 +2888,7 @@ should I follow up on" to "I'll send via lemlist".
|
|
|
2886
2888
|
leadbay_refine_audience: { "name": "leadbay_refine_audience", "short_description": "Refine the kind of leads Leadbay surfaces beyond firmographics, with a\nfree-text instruction. Handles the clarification round-trip if the new\nprompt is ambiguous.\n", "arguments": [{ "name": "instruction", "description": "The refinement (e.g. 'focus on hospitals running their own IT'). Set to plain English.", "required": true }], "expected_calls": ["leadbay_refine_prompt", "leadbay_account_status"], "failure_modes": ["Calls leadbay_answer_clarification on the user's behalf instead of surfacing the clarification verbatim", "Glosses over the clarification options instead of presenting them as offered", "Promises immediate effect when status='applied' actually triggers an async intelligence recompute"] },
|
|
2887
2889
|
leadbay_research_a_domain: { "name": "leadbay_research_a_domain", "short_description": "Resolve a company by name or domain across the user's visible Discover,\nMonitor, and Activate corpus, then return everything Leadbay knows about it.\n", "arguments": [{ "name": "domain", "description": "Company name or domain (for example 'Acme Corporation' or 'acme.com'). The legacy argument key remains `domain` for client compatibility.", "required": true }], "expected_calls": ["leadbay_research_lead_by_name_fuzzy"], "failure_modes": ["Fabricates qualification answers not present in any tool response", "Calls leadbay_import_and_qualify before searching the existing visible corpus", "Treats the active lens as the entire search universe when the user did not request a lens scope", "Imports a missing company without the user's explicit permission", "Renders the research result as a freeform narrative instead of the canonical research-company-card layout (the card with header score bar, pill row, signal sections, contacts table is the structural contract; commentary belongs ABOVE or BELOW it)"] },
|
|
2888
2890
|
leadbay_setup_team_prospecting: { "name": "leadbay_setup_team_prospecting", "short_description": "Manager-led prospecting setup: conversationally turn a natural-language\naudience ask into a Leadbay lens, validate the candidate leads, and\npersist them as one or more named campaigns the rep(s) can work\nthrough. Closes #3630 US3 end-to-end (within the current\ncreator-scoped campaign visibility model).\n", "arguments": [{ "name": "audience", "description": "Natural-language audience description (e.g. 'plumbing companies with 10-50 employees in Seine-Maritime'). The lens-creation step (`leadbay_refine_prompt` \u2192 `leadbay_create_lens`) interprets it. A country name is not a scope here \u2014 this workspace already covers exactly one country, so drop it and keep the rest of the description; a DIFFERENT country cannot be targeted at all.", "required": true }, { "name": "rep_split", "description": "Optional: how to split the validated leads into per-rep campaigns. Free text \u2014 e.g. 'split by city' or 'one campaign per rep: John gets Tulsa, Sarah gets OKC'. Splitting by country is not a split \u2014 the workspace is single-country.", "required": false }], "expected_calls": ["leadbay_refine_prompt", "leadbay_create_lens", "leadbay_promote_lens", "leadbay_pull_leads", "leadbay_research_lead_by_id", "leadbay_create_campaign", "leadbay_add_leads_to_campaign"], "failure_modes": ["Skips the validation step \u2014 creates a campaign of unvetted leads from a freshly-created lens without giving the manager a chance to drop weak fits", "Creates ONE campaign for all reps without asking about the split \u2014 the user explicitly mentioned per-rep distribution and the prompt should honor it", "Pretends the backend supports cross-user assignment \u2014 campaigns are owned by the caller (creator-scoped). Surface this honestly instead of fabricating an assignment model", "Asks ALL clarifying questions inline before tool calls \u2014 instead, run the lens refinement loop with `leadbay_refine_prompt` which handles the clarification protocol natively"] },
|
|
2889
|
-
leadbay_top_accounts_to_activate: { "name": "leadbay_top_accounts_to_activate", "short_description": 'Build a ranked account-conquest plan from Leadbay data \u2014 the accounts worth\nactivating, each with a motif, a pitch and a checklist, ranked by the\nstrongest Leadbay signal. Every figure carries its source, and anything\nLeadbay can\'t measure is shown as OMITTED rather than estimated. Uses\n`leadbay_bulk_qualify_leads` and `leadbay_enrich_titles`. Trigger on\n"top 50 accounts to activate", "who should we go after".\n', "arguments": [{ "name": "count", "description": "Optional: how many accounts the plan should hold (default 50).", "required": false }, { "name": "territory", "description": "Optional: restrict the plan to a territory (e.g. 'Indre-et-Loire', 'R\xE9gion Ouest'). Sets geography on the Discover lens. A country is not a territory \u2014 this workspace already covers exactly one country.", "required": false }], "expected_calls": ["leadbay_account_status", "leadbay_get_qualification_questions", "leadbay_pull_leads", "leadbay_pull_followups", "leadbay_bulk_qualify_leads", "leadbay_qualify_status", "leadbay_scan_portfolio_signals", "leadbay_enrich_titles", "leadbay_bulk_enrich_status", "leadbay_account_history", "leadbay_artifact_kit", "leadbay_new_lens", "leadbay_adjust_audience"], "failure_modes": ["Invents, estimates or proxies a revenue-realized figure \u2014 the single worst failure. Leadbay does not hold what an account buys, and headcount, sector and lead score are NOT proxies for it.", "Sorts by cash-to-capture, synthesizing a revenue figure per account purely to make that ranking work. Leadbay has no revenue data: rank by the Leadbay signal, say so in the header, and never invent the key.", "Emits \u20AC figures with no provenance class, so modelled numbers read as measured fact in front of a paying client.", "Skips the PROVENANCE LEDGER, or drops un-sourceable fields from it instead of rendering them as OMITTED \u2014 which hides the gap.", "Fabricates registry/TAM counts (France or regional company counts) instead of querying the registry or marking the figure NOT COMPUTED. Leadbay does not proxy SIRENE.", "Invents the five qualification questions from this prompt's own recommendations instead of reading the org's actual questions via leadbay_get_qualification_questions.", "Leaves the deck's live layer dead \u2014 qualification and enrichment handles never wired in, so the pills and contacts stay empty while the deck still looks finished.", "Invents lead ids to make the qualification pills appear populated.", "Fabricates a plausible-sounding signal ('just won a public tender') for an account whose signals were never read. No signal read means an explicit dash.", "Assigns a motif outside the closed set of six, or assigns SAUVETAGE / PLAN DE COMPTE / MONT\xC9E EN GAMME / R\xC9VEIL from a lead score or sector when order history was never available.", "Labels Monitor membership as 'is a client' \u2014 Monitor is a Leadbay view whose membership is decided by lens scoring, not by whether the company ever bought anything.", "Launches
|
|
2891
|
+
leadbay_top_accounts_to_activate: { "name": "leadbay_top_accounts_to_activate", "short_description": 'Build a ranked account-conquest plan from Leadbay data \u2014 the accounts worth\nactivating, each with a motif, a pitch and a checklist, ranked by the\nstrongest Leadbay signal. Every figure carries its source, and anything\nLeadbay can\'t measure is shown as OMITTED rather than estimated. Uses\n`leadbay_bulk_qualify_leads` and `leadbay_enrich_titles`. Trigger on\n"top 50 accounts to activate", "who should we go after".\n', "arguments": [{ "name": "count", "description": "Optional: how many accounts the plan should hold (default 50).", "required": false }, { "name": "territory", "description": "Optional: restrict the plan to a territory (e.g. 'Indre-et-Loire', 'R\xE9gion Ouest'). Sets geography on the Discover lens. A country is not a territory \u2014 this workspace already covers exactly one country.", "required": false }], "expected_calls": ["leadbay_account_status", "leadbay_get_qualification_questions", "leadbay_pull_leads", "leadbay_pull_followups", "leadbay_bulk_qualify_leads", "leadbay_qualify_status", "leadbay_scan_portfolio_signals", "leadbay_enrich_titles", "leadbay_bulk_enrich_status", "leadbay_account_history", "leadbay_artifact_kit", "leadbay_new_lens", "leadbay_adjust_audience"], "failure_modes": ["Invents, estimates or proxies a revenue-realized figure \u2014 the single worst failure. Leadbay does not hold what an account buys, and headcount, sector and lead score are NOT proxies for it.", "Sorts by cash-to-capture, synthesizing a revenue figure per account purely to make that ranking work. Leadbay has no revenue data: rank by the Leadbay signal, say so in the header, and never invent the key.", "Emits \u20AC figures with no provenance class, so modelled numbers read as measured fact in front of a paying client.", "Skips the PROVENANCE LEDGER, or drops un-sourceable fields from it instead of rendering them as OMITTED \u2014 which hides the gap.", "Fabricates registry/TAM counts (France or regional company counts) instead of querying the registry or marking the figure NOT COMPUTED. Leadbay does not proxy SIRENE.", "Invents the five qualification questions from this prompt's own recommendations instead of reading the org's actual questions via leadbay_get_qualification_questions.", "Leaves the deck's live layer dead \u2014 qualification and enrichment handles never wired in, so the pills and contacts stay empty while the deck still looks finished.", "Invents lead ids to make the qualification pills appear populated.", "Fabricates a plausible-sounding signal ('just won a public tender') for an account whose signals were never read. No signal read means an explicit dash.", "Assigns a motif outside the closed set of six, or assigns SAUVETAGE / PLAN DE COMPTE / MONT\xC9E EN GAMME / R\xC9VEIL from a lead score or sector when order history was never available.", "Labels Monitor membership as 'is a client' \u2014 Monitor is a Leadbay view whose membership is decided by lens scoring, not by whether the company ever bought anything.", "Launches enrichment on the whole plan without consent. Asking for a plan is not authorization to use quota on 50 accounts.", "Re-launches enrichment from inside the built deck when a bulk handle already exists this session \u2014 uses the user's quota twice.", "Forces the interactive deck without offering it first, or ships the deck INSTEAD of a chat answer that stands on its own.", "Refuses the task because revenue data is missing, instead of delivering the conquest plan and naming what a cash-ranked version would need.", "Ends the turn without a ranked list of real accounts \u2014 gating the whole plan on a NON-blocking question (a MISSING territory, a missing lens, or a 3-vs-5 qualification-question gap) so the user gets a plan-of-a-plan. Only two things may stop delivery: an unresolvable company-identity mismatch, and a territory naming a foreign or supra-national scope. Every other open question rides alongside the delivered plan.", "Stops after the discovery contact preview to wait for enrichment consent, delivering no plan that turn \u2014 the ranked plan ships first; the reveal is offered alongside it.", "Renders a contact channel enrichment never returned (e.g. a phone link when only email was approved and revealed) instead of showing the returned channels and marking the rest omitted."] },
|
|
2890
2892
|
leadbay_work_campaign: { "name": "leadbay_work_campaign", "short_description": "Work a campaign as a real outreach session: pick the campaign,\nassess what the user has (phones / emails / coords), then PROPOSE\nthe right session mode (call sheet, email sheet, enrich titles\nfirst, map). After they pick, render \u2014 and as they dictate\noutcomes per lead, record both note + epilogue via\n`leadbay_report_outreach` in one round trip.\n", "arguments": [{ "name": "campaign", "description": "Campaign name (fuzzy match against your own campaigns) or campaign UUID. Omit to list and pick interactively.", "required": false }, { "name": "mode", "description": "Optional: skip the readiness-assessment proposal and jump directly into 'call_sheet' / 'email_sheet' / 'map' / 'enrich_first'. Omit (recommended) and let the prompt propose based on the data.", "required": false }], "expected_calls": ["leadbay_list_campaigns", "leadbay_campaign_call_sheet", "leadbay_enrich_titles", "leadbay_report_outreach"], "failure_modes": ["Renders the call sheet immediately without proposing the right mode \u2014 if 60% of leads have no contacts, calling is futile; enrich first. Always assess `readiness` first.", "Auto-renders the map widget without asking \u2014 maps are intrusive when the user just wants to scroll a list. Map mode is a proposed option, not a default.", "Proposes map mode after the user has previously said they don't like maps \u2014 check conversation memory before adding 'View on a map' to the options list.", "Calls `leadbay_campaign_progression` instead of `leadbay_campaign_call_sheet` \u2014 progression has counts but no phones / LinkedIn / call-ready data; the user can't actually dial from progression rows.", "Renders contacts WITHOUT making the phone number a `[bare](tel:URL)` link \u2014 on mobile that breaks one-tap calling, which is the whole point of the cheat sheet.", "Records outreach WITHOUT epilogue_status \u2014 leaves the lead's pipeline state unchanged; the rep then sees the same lead surfaced again next session.", "Records outreach WITHOUT verification \u2014 verification.source/ref is REQUIRED. For calls, pass `{source: 'user_confirmed', ref: <user's exact words>}`.", "Loops through ALL leads in a 50-lead campaign before recording any outreach \u2014 the call-then-record loop must be per-lead, not batched."] }
|
|
2891
2893
|
};
|
|
2892
2894
|
var PROMPT_CATALOG_HEADER = `This server exposes the following workflow prompts via \`prompts/list\` and \`prompts/get\`. Some MCP clients render them as slash commands; if your client does not, you (the agent) should invoke them directly via \`prompts/get\` when the user's request matches one of the triggers described below.`;
|
|
@@ -3627,7 +3629,7 @@ var LeadbayClient = class _LeadbayClient {
|
|
|
3627
3629
|
// Cancelled while queued — nothing was ever put on the wire, which is what
|
|
3628
3630
|
// makes this safe to report as "not sent" even for a write.
|
|
3629
3631
|
cancelledBeforeSendError() {
|
|
3630
|
-
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
|
|
3632
|
+
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 ran.");
|
|
3631
3633
|
}
|
|
3632
3634
|
releaseSemaphore() {
|
|
3633
3635
|
this.activeRequests--;
|
|
@@ -3967,6 +3969,11 @@ var LeadbayClient = class _LeadbayClient {
|
|
|
3967
3969
|
if (status === 401) {
|
|
3968
3970
|
return this.makeError("AUTH_EXPIRED", "Leadbay rejected this request (401)", retried ? "Tokens don't expire on a timer, so this isn't stale. Already auto-retried once and it 401'd again \u2014 usually a Leadbay-side hiccup, but can also mean the user logged out. Try again shortly, else report it." : "Tokens don't expire on a timer, so this isn't stale. This call wasn't auto-retried, so it's the first attempt \u2014 a Leadbay-side hiccup, or the user logged out. Try again once, else report it.", endpoint, null, status);
|
|
3969
3971
|
}
|
|
3972
|
+
const refusalCode = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.code;
|
|
3973
|
+
if (status === 429 && (refusalCode === "rate_limited" || refusalCode === "active_job_cap") && /^\/mcp\/(search|qualify)(\?|$)/.test(endpoint)) {
|
|
3974
|
+
const wait = retryAfter ? `${retryAfter}s` : "a few minutes";
|
|
3975
|
+
return this.makeError("QUOTA_EXCEEDED", refusalCode === "rate_limited" ? `Too many lead jobs started this hour \u2014 retry in ${wait}` : "Too many lead jobs running at once", (refusalCode === "rate_limited" ? `Leadbay starts at most 10 new lead jobs per org in a rolling hour. ` : `Leadbay runs at most 3 lead jobs per org at a time. `) + `This is a pacing limit, not credits: a top-up does not lift it, so do not offer one and do not talk about credits. Jobs already started keep running \u2014 read them with leadbay_lead_job_status meanwhile. ` + (refusalCode === "rate_limited" ? `Wait ${wait}, then retry the same call once. If it is refused again, the hour has not rolled over yet: wait longer rather than retrying in a loop.` : `Retry this call when one of the running jobs finishes.`), endpoint, retryAfter, status);
|
|
3976
|
+
}
|
|
3970
3977
|
if (status === 429 || status === 402 || parsed?.error === "quota_exceeded" || parsed?.error?.code === "quota_exceeded") {
|
|
3971
3978
|
const hintBase = retryAfter ? `Wait ${retryAfter}s before retrying` : "Wait, then retry";
|
|
3972
3979
|
return this.makeError(
|
|
@@ -4562,9 +4569,9 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
4562
4569
|
|
|
4563
4570
|
## RENDER (quick)
|
|
4564
4571
|
|
|
4565
|
-
Report user + org
|
|
4566
|
-
|
|
4567
|
-
\`last_requested_lens_name\`, never the id). SILENT on quota
|
|
4572
|
+
Report user + org; show quota only when asked (quota / account status) or a
|
|
4573
|
+
window is exhausted. NEVER mention the lens unless asked (use
|
|
4574
|
+
\`last_requested_lens_name\`, never the id). SILENT on quota when
|
|
4568
4575
|
\`quota_error\` set, \`unlimited_credits\` true, or quota null. Else render
|
|
4569
4576
|
Daily/Weekly/Monthly from \`quota.user\` (fall back to \`quota.org\` if \`user\`
|
|
4570
4577
|
absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table when
|
|
@@ -4572,7 +4579,7 @@ absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table whe
|
|
|
4572
4579
|
|
|
4573
4580
|
---
|
|
4574
4581
|
|
|
4575
|
-
Show the user's account state \u2014 admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **
|
|
4582
|
+
Show the user's account state \u2014 admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **When you show quota, show it the way the web app does \u2014 a percentage-used + dollar-spend gauge per window, never raw "credits".** Each window in \`quota.<group>.spend[]\` carries \`current_units\` / \`max_units\` in dollar_cents (% used = the ratio, $ = \`/100\`); the \`quota.<group>.resources[]\` list gives the per-resource usage breakdown (\`count\`, plus \`max_units\` when a per-resource cap exists). **Pre-check the \`LENS_EXTRA_REFILL\` resource here before calling \`leadbay_extend_lens\`** \u2014 look in **\`quota.org.resources[]\`** first (admins), and fall back to **\`quota.user.resources[]\`** when \`quota.org\` is absent (non-admin callers only get the \`user\` group), matching the resource type **case-insensitively** (it may arrive as \`LENS_EXTRA_REFILL\` or \`lens_extra_refill\`). Its full requested batch must fit into the remaining daily quota or the call is rejected outright. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption.
|
|
4576
4583
|
|
|
4577
4584
|
**Top-ups always beat waiting.** When a quota window is hit, the user has two options: wait for the window reset (\`resets_at\` in each quota entry) OR top up AI credits. Top-ups clear the throttle IMMEDIATELY; they are not subject to the same window. When you tell the user about a 429 / quota exhaustion, ALWAYS surface both options \u2014 "wait until <reset>" or "top up now (I can generate the link)" \u2014 and let them pick. Never default-recommend "wait until tomorrow" when a 30-second top-up unblocks the same operation.
|
|
4578
4585
|
|
|
@@ -4610,7 +4617,7 @@ Some Leadbay tool responses include a \`_meta.notifications\` array listing **ba
|
|
|
4610
4617
|
|
|
4611
4618
|
- **Previous turn / before an MCP restart, and the user has NOT asked about it** \u2014 don't poll for it in the background. Simply continue the conversation; the next time you call any tool, the completed-work entry appears in \`_meta.notifications\` (also on \`leadbay_account_status.notifications\`). This is the ambient push path \u2014 leave it to do its job. **But if the user explicitly asks for status or to "wait for it to finish"** (e.g. a multi-turn flow where a job was launched in a prior turn and this turn says "wait for enrichment to finish, then \u2026"), DO poll its status tool now until done, exactly as for a this-turn job below \u2014 the ambient push only surfaces *completed* work, so it can't answer a live "is it done / wait for it" request while the job is still running.
|
|
4612
4619
|
- **This turn (you just launched it)** \u2014 the DEFAULT is: do NOT end your turn on the "launched" ack; stay active and poll the job's status tool in a loop until it reports done, then report the finished result yourself, rather than spinning forever or deferring the result to a later turn. (Two exceptions, detailed below: the user explicitly asked NOT to wait / to run it in the background; or it's a large qualification/import that's async by design \u2014 in those cases hand back the handle instead of looping.) Each status tool has its OWN terminal signal \u2014 poll until:
|
|
4613
|
-
- \`leadbay_bulk_enrich_status\` \u2192 \`all_done:true\` \u2014 OR \`overall_progress.done\` holds steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (
|
|
4620
|
+
- \`leadbay_bulk_enrich_status\` \u2192 \`all_done:true\` \u2014 OR \`overall_progress.done\` holds steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (a reservation the provider never answers stays \`done:false\`, so \`all_done\` can stay false forever). Don't call a plateau from the first few back-to-back reads \u2014 early on \`done\` sits flat while the backend spins up. Once the plateau is real, report what resolved and name what didn't.
|
|
4614
4621
|
- \`leadbay_qualify_status\` \u2192 \`still_running\` is empty: every launched lead has finished or failed. (\`in_progress\` also reads \`false\` on the fast path, but it can be \`null\` on the legacy/fallback read \u2014 so treat an empty \`still_running\` as terminal on its own; only require \`in_progress:false\` when that field is actually present.) LIKE imports, large qualification runs are async by design: \`leadbay_bulk_qualify_leads\` defaults to \`wait_for_completion:false\` for \`count > 5\` or chained workflows because blocking can time out, and \`leadbay_qualify_status\` may take minutes/hours. So don't force a long polling loop on a big run \u2014 return the handle/progress and let completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked to wait, or it's a small run that finishes quickly. A small \`wait_for_completion:true\` run you can poll to \`still_running\` empty inline.
|
|
4615
4622
|
- \`leadbay_import_status\` \u2192 \`status:"complete"\` (or \`"failed"\`). BUT imports are the exception to the stay-active loop: a large \`leadbay_import_leads({wait_for_completion:false})\` is meant to return a handle and resolve over minutes, and the tool does ONE refresh pass per call. Don't block the conversation looping on it \u2014 surface the returned progress/handle and let the completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked you to wait for the import, or it's a small import that finishes quickly.
|
|
4616
4623
|
|
|
@@ -4628,10 +4635,10 @@ Mirror the Leadbay web quota widget: three windows side by side \u2014 **Daily**
|
|
|
4628
4635
|
$ cap** figure, with a per-resource usage breakdown underneath. **Never speak in
|
|
4629
4636
|
raw "credits"** for quota \u2014 the unit is a percentage and a dollar spend.
|
|
4630
4637
|
|
|
4631
|
-
**
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4638
|
+
**Show the quota only when it matters** \u2014 when the user asks about their quota,
|
|
4639
|
+
usage or account status, or when a window is exhausted and blocks what they
|
|
4640
|
+
asked for. A plain "what account am I connected to?" is answered with user +
|
|
4641
|
+
org alone. Even then, the silence gate below comes first.
|
|
4635
4642
|
|
|
4636
4643
|
**Silence gate (check FIRST).** Render NOTHING about quota when any of these
|
|
4637
4644
|
holds \u2014 do not mention quota at all, do not say "unreadable", never tell the user
|
|
@@ -4691,7 +4698,7 @@ days"), computed against now \u2014 mirroring the widget's "r\xE9initialis\xE9 d
|
|
|
4691
4698
|
raw value is an ISO-8601 timestamp.
|
|
4692
4699
|
|
|
4693
4700
|
**Top-up (optional, subordinate).** When \`quota.topup\` is present, you MAY add one
|
|
4694
|
-
small line below the windows: \`Top-up: $<remaining_cents/100> of $<total_credit_cents/100> left\`.
|
|
4701
|
+
small line below the windows: \`Top-up: $<(remaining_cents / 100).toFixed(2)> of $<(total_credit_cents / 100).toFixed(2)> left\`.
|
|
4695
4702
|
Keep it secondary \u2014 the three window gauges are the headline. Omit when null.
|
|
4696
4703
|
|
|
4697
4704
|
**Legend** (once, below): \`\` \`\u25B0\` used \xB7 \`\u25B1\` remaining \`\`.
|
|
@@ -4699,7 +4706,7 @@ Keep it secondary \u2014 the three window gauges are the headline. Omit when nul
|
|
|
4699
4706
|
|
|
4700
4707
|
---
|
|
4701
4708
|
|
|
4702
|
-
WHEN TO USE:
|
|
4709
|
+
WHEN TO USE: when the user asks about their account, quota or version, after a 429 to explain to the user which resource window was exhausted and when it resets (and to offer the top-up alternative), and after the user signals a top-up so the agent can resume the interrupted workflow.
|
|
4703
4710
|
|
|
4704
4711
|
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.
|
|
4705
4712
|
`;
|
|
@@ -4923,7 +4930,7 @@ WHEN NOT TO USE: the user wants a plain data answer (route to leadbay_pull_leads
|
|
|
4923
4930
|
`;
|
|
4924
4931
|
var leadbay_bulk_enrich_status = `Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Pass the \`notification_id\` for the job counters in one call, and/or the \`lead_ids\` + \`titles\` + \`email\` / \`phone\` the launch returned for per-lead progress. \`lead_ids\` alone is a valid call and is the reliable one: the job lookup is a scan of your recent notifications, so an archived job may not be found, and an enrichment notification does not always carry counters (then the tool answers \`ENRICH_JOB_NO_COUNTERS\` with the backend's running/finished flag and asks for \`lead_ids\`) \u2014 but the leads always answer. When \`include_contacts=true\` (opt-in), includes each contact's email/phone_number/job_title/enrichment.done.
|
|
4925
4932
|
|
|
4926
|
-
WHEN TO USE: poll this REPEATEDLY after leadbay_enrich_titles returns a \`notification_id\`, staying active until the job is done \u2014 don't stop after one check, and don't hand the turn back to the user while progress is still climbing. "Done" = \`all_done:true\`, OR \`overall_progress.done\` has held steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (
|
|
4933
|
+
WHEN TO USE: poll this REPEATEDLY after leadbay_enrich_titles returns a \`notification_id\`, staying active until the job is done \u2014 don't stop after one check, and don't hand the turn back to the user while progress is still climbing. "Done" = \`all_done:true\`, OR \`overall_progress.done\` has held steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (a reservation the provider never answers stays \`done:false\`, so \`all_done\` can stay false forever \u2014 don't spin indefinitely; a contact with nothing found does flip, to \`done:true\` with \`credits_used:0\`). Do NOT declare a plateau from the first few back-to-back reads: right after launch, \`overall_progress.done\` can sit flat while the backend is still spinning the job up, so space your polls out and give it real elapsed time before treating a flat count as terminal. Also do NOT declare a plateau while the result carries \`partial_failures\` \u2014 a flat \`done\` there means a transient per-lead fetch error (e.g. a 429), NOT an unresolvable contact; keep polling (respecting any \`retry_after\`) or surface it as a temporary status failure, rather than reporting those leads as permanently unresolved. Default \`include_contacts=false\` for the cheap interim polls; set \`include_contacts=true\` on the read you report from to pull each lead's enriched contacts for the completion report.
|
|
4927
4934
|
|
|
4928
4935
|
WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id \u2014 that already includes enriched contacts for a single lead.
|
|
4929
4936
|
|
|
@@ -4957,17 +4964,17 @@ a launcher only for a subset that never started, never for the whole batch:
|
|
|
4957
4964
|
that are the ones that never started. Only then call
|
|
4958
4965
|
\`leadbay_bulk_qualify_leads({leadIds, lensId})\` for exactly those ids. A lead
|
|
4959
4966
|
that is merely slow looks identical to one that never launched over a few
|
|
4960
|
-
polls, and re-launching it
|
|
4967
|
+
polls, and re-launching it uses the user's quota twice \u2014 when unsure, tell the user
|
|
4961
4968
|
rather than guess.
|
|
4962
4969
|
|
|
4963
4970
|
|
|
4964
|
-
## QUOTA
|
|
4971
|
+
## QUOTA, NOT CREDITS
|
|
4965
4972
|
|
|
4966
|
-
Enrichment consumes QUOTA (the per-window allowance), not a separate credit wall.
|
|
4973
|
+
Enrichment consumes QUOTA (the per-window allowance), not a separate credit wall. The result's \`credits_remaining\` field is **advisory internal context only \u2014 do NOT display it**: it comes from \`billing.ai_credits\` (a consumed counter, not remaining), so printing \`_(N credits remaining)_\` can show a fresh/quota-backed account a false "0 remaining." Never render a credits balance; the \`leadbay_account_status\` quota gauge is the only place the user's standing is shown. Do NOT report a "credits used" figure for this run either: the per-contact cost can't be scoped to this specific enrichment (a lead's contact list mixes in earlier runs), so any "X used" number would be misleading.
|
|
4967
4974
|
|
|
4968
4975
|
## COMPLETION REPORT \u2014 what to tell the user when the job is done
|
|
4969
4976
|
|
|
4970
|
-
The result always carries \`overall_progress:{done,total,done_ratio}\` and, with \`include_contacts:true\`, \`leads[]\` each with contacts' \`email\` / \`phone_number\` / \`job_title\` / \`enrichment.done\`. \`bulk_progress:{total_count,success_count,failure_count,quota_hit_count}\` is present only when you passed a \`notification_id\` AND the job was found; derive counts from \`overall_progress\` rather than assuming \`bulk_progress\` is there. With \`lead_ids\`, each entry carries \`enrichment_progress:{done,total}\` \u2014 \`
|
|
4977
|
+
The result always carries \`overall_progress:{done,total,done_ratio}\` and, with \`include_contacts:true\`, \`leads[]\` each with contacts' \`email\` / \`phone_number\` / \`job_title\` / \`enrichment.done\`. \`bulk_progress:{total_count,success_count,failure_count,quota_hit_count}\` is present only when you passed a \`notification_id\` AND the job was found; derive counts from \`overall_progress\` rather than assuming \`bulk_progress\` is there. With \`lead_ids\`, each entry carries \`enrichment_progress:{done,total}\` \u2014 \`total\` counts the reservations this run made (scoped to the \`titles\` it enriched, and to reservations whose \`enrichment.email_requested\` / \`phone_requested\` match the \`email\` / \`phone\` you pass, so a lead's pre-existing CFO email cannot inflate a CEO run) and \`done\` counts those that have settled (\`enrichment.done:true\`, found or not). The \`enrichment\` record sits on the \`source:"paid"\` entry and never carries \`email\` / \`phone_number\`: the revealed values land on the same person's \`source:"org"\` entry (match on name and \`job_title\`). Read what landed from the org entries \u2014 an org entry with \`email\` = email found, with \`phone_number\` = phone found \u2014 and a settled paid entry with \`credits_used:0\` = nothing found for that person. \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), so it can include contacts of other roles that were enriched in earlier runs \u2014 filter your report to the \`titles\` this bulk enriched (match each contact's \`job_title\`), don't attribute a pre-existing email of an unrelated role to this run. Report it yourself in the SAME turn, without a reprompt and without deferring to a scheduled re-check: name which of the just-enriched contacts now have emails / phones, the done/total counts, and \u2014 if \`bulk_progress\` is present \u2014 any \`quota_hit_count\` (if non-zero, say some contacts were skipped because the quota window was exhausted, and point to \`leadbay_account_status\` for the wait-or-top-up choice). If you stopped on a plateau (not \`all_done\`), say so plainly \u2014 report the resolved contacts and name the ones that didn't resolve, keyed to the requested channel and the returned fields (no \`email\` \u2192 "no email found"; no \`phone_number\` \u2192 "no phone number found") \u2014 rather than implying the job fully finished. Do NOT print a credits-remaining line.
|
|
4971
4978
|
`;
|
|
4972
4979
|
var leadbay_bulk_qualify_leads = `Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass \`wait_for_completion:false\` to return quickly with \`{status:'running', notification_id}\`; poll leadbay_qualify_status with that id. With \`wait_for_completion\` omitted/true, the legacy behavior polls until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null \`ai_agent_lead_score\`) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads.
|
|
4973
4980
|
|
|
@@ -4984,7 +4991,7 @@ WHEN NOT TO USE: to qualify a single specific lead \u2014 that's leadbay_qualify
|
|
|
4984
4991
|
Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
|
|
4985
4992
|
\`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
|
|
4986
4993
|
running result, that work is queued on Leadbay and runs to completion, and the
|
|
4987
|
-
quota it
|
|
4994
|
+
quota it uses is already committed. A discovery, preview or \`dry_run\` result
|
|
4988
4995
|
launched nothing and is not covered here.
|
|
4989
4996
|
|
|
4990
4997
|
The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
|
|
@@ -4992,7 +4999,7 @@ waiting, never the job. \`cancelled: true\` means we stopped watching, not that
|
|
|
4992
4999
|
work stopped. What to do next depends on what you are holding:
|
|
4993
5000
|
|
|
4994
5001
|
- **A handle.** Poll the status tool with it, and do not launch the work that
|
|
4995
|
-
handle covers a second time \u2014 that
|
|
5002
|
+
handle covers a second time \u2014 that uses the quota again on the same rows.
|
|
4996
5003
|
\`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
|
|
4997
5004
|
under that name. A qualification started by \`leadbay_import_and_qualify\` has no
|
|
4998
5005
|
notification of its own: resume it with
|
|
@@ -5006,7 +5013,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
5006
5013
|
finished. Calling the same tool again with the same arguments will usually hand
|
|
5007
5014
|
back the job already launched rather than starting a second one, but that guard
|
|
5008
5015
|
is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
|
|
5009
|
-
are about to re-run before you
|
|
5016
|
+
are about to re-run before you use the user's quota on it.
|
|
5010
5017
|
|
|
5011
5018
|
|
|
5012
5019
|
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\`.
|
|
@@ -5496,29 +5503,29 @@ WHEN NOT TO USE: for bulk enrichment by job title across many leads \u2014 use l
|
|
|
5496
5503
|
## A launched job cannot be stopped, and this tool has no retry guard
|
|
5497
5504
|
|
|
5498
5505
|
Leadbay has no cancel. Once this call returns having actually launched, the work
|
|
5499
|
-
is queued on Leadbay and runs to completion, and the quota it
|
|
5500
|
-
committed. A \`dry_run\` result reached no backend and
|
|
5506
|
+
is queued on Leadbay and runs to completion, and the quota it uses is already
|
|
5507
|
+
committed. A \`dry_run\` result reached no backend and used nothing. The user
|
|
5501
5508
|
cancelling in the chat, a request timeout, or a closed stream stops YOUR waiting,
|
|
5502
5509
|
never the job.
|
|
5503
5510
|
|
|
5504
5511
|
Unlike the composite launchers, this tool has **no double-launch guard**: calling
|
|
5505
|
-
it again always issues a new
|
|
5512
|
+
it again always issues a new launch that uses quota again, even seconds later with identical
|
|
5506
5513
|
arguments. So when a call returns nothing at all, do not simply retry. Read the
|
|
5507
5514
|
record back first \u2014 \`leadbay_research_lead_by_id\` or \`leadbay_get_contacts\` for a
|
|
5508
5515
|
lead, \`leadbay_account_status\` for background work that has since finished \u2014 to
|
|
5509
5516
|
see whether the launch already landed, and tell the user what you are about to
|
|
5510
|
-
|
|
5517
|
+
run before running it again.
|
|
5511
5518
|
|
|
5512
5519
|
|
|
5513
5520
|
## QUOTA, NOT CREDITS
|
|
5514
5521
|
|
|
5515
|
-
Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead.
|
|
5522
|
+
Enrichment is gated by QUOTA (the per-window allowance in \`leadbay_account_status\`), not a credit balance. **Never pre-refuse because a credit number looks low or zero** \u2014 a freemium/fresh account with quota left can enrich even when its credit counter reads 0. The reveal either fits the remaining quota or the backend returns 429 (\`quota_exceeded\`); only THEN surface the exhausted window + wait-or-top-up choice. The \`credits_remaining\` field on the result is **advisory internal context only \u2014 do NOT display it**. Because it can read \`0\` on an account that still has quota, printing \`_(N credits remaining)_\` would falsely tell the user they're out. Do not render a credits balance at all; if the user asks where they stand, call \`leadbay_account_status\` and show the quota gauge instead.
|
|
5516
5523
|
|
|
5517
5524
|
**Channels: when the user asks to enrich a contact without naming a channel, confirm scope via \`ask_user_input_v0\`** \u2014 \`"Enrich email only, or email + phone? (phone uses more quota)"\` \u2192 \`["Email only", "Email + phone"]\` \u2014 then pass the chosen \`email\`/\`phone\` flags. Skip the question only if they already said which channel(s) they want.
|
|
5518
5525
|
|
|
5519
5526
|
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\`.
|
|
5520
5527
|
`;
|
|
5521
|
-
var leadbay_enrich_titles = `Order contact enrichments by job title across many leads. Contacts are NOT returned by default with a lead (Leadbay keeps enrichment out-of-band
|
|
5528
|
+
var leadbay_enrich_titles = `Order contact enrichments by job title across many leads. Contacts are NOT returned by default with a lead (Leadbay keeps enrichment out-of-band); the agent requests them on demand via this tool when it's ready to actually reach out. Two modes: (A) NO \`titles\` param \u2014 returns the available titles + Leadbay's \`title_suggestions\` + \`auto_included_titles\` + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) \`titles\` given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns \`{status:'quota_exceeded'}\` cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error.
|
|
5522
5529
|
|
|
5523
5530
|
WHEN TO USE: as the agent's go-to enrichment entry point, immediately before proposing outreach.
|
|
5524
5531
|
|
|
@@ -5529,7 +5536,7 @@ WHEN NOT TO USE: to enrich a single named contact \u2014 that's leadbay_enrich_c
|
|
|
5529
5536
|
Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
|
|
5530
5537
|
\`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
|
|
5531
5538
|
running result, that work is queued on Leadbay and runs to completion, and the
|
|
5532
|
-
quota it
|
|
5539
|
+
quota it uses is already committed. A discovery, preview or \`dry_run\` result
|
|
5533
5540
|
launched nothing and is not covered here.
|
|
5534
5541
|
|
|
5535
5542
|
The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
|
|
@@ -5537,7 +5544,7 @@ waiting, never the job. \`cancelled: true\` means we stopped watching, not that
|
|
|
5537
5544
|
work stopped. What to do next depends on what you are holding:
|
|
5538
5545
|
|
|
5539
5546
|
- **A handle.** Poll the status tool with it, and do not launch the work that
|
|
5540
|
-
handle covers a second time \u2014 that
|
|
5547
|
+
handle covers a second time \u2014 that uses the quota again on the same rows.
|
|
5541
5548
|
\`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
|
|
5542
5549
|
under that name. A qualification started by \`leadbay_import_and_qualify\` has no
|
|
5543
5550
|
notification of its own: resume it with
|
|
@@ -5551,7 +5558,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
5551
5558
|
finished. Calling the same tool again with the same arguments will usually hand
|
|
5552
5559
|
back the job already launched rather than starting a second one, but that guard
|
|
5553
5560
|
is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
|
|
5554
|
-
are about to re-run before you
|
|
5561
|
+
are about to re-run before you use the user's quota on it.
|
|
5555
5562
|
|
|
5556
5563
|
|
|
5557
5564
|
## ENRICHMENT CONSUMES QUOTA \u2014 the model to reason with
|
|
@@ -5560,25 +5567,25 @@ Each email reveal and each phone reveal **consumes quota** (the per-window daily
|
|
|
5560
5567
|
|
|
5561
5568
|
## CONSENT \u2014 email is the default channel; phone is opt-in; never launch silently
|
|
5562
5569
|
|
|
5563
|
-
The \`email\` channel defaults **ON**; \`phone\` defaults **OFF**. A bare "enrich these titles" is **NOT** consent to
|
|
5570
|
+
The \`email\` channel defaults **ON**; \`phone\` defaults **OFF**. A bare "enrich these titles" is **NOT** consent to use quota.
|
|
5564
5571
|
|
|
5565
5572
|
**When the user asks to enrich without naming channels, ASK which channels via \`ask_user_input_v0\`** \u2014 email is included by default, so the real question is whether to add phone: \`"Enrich email only, or email + phone? (phone reveals use more quota)"\` \u2192 \`["Email only", "Email + phone"]\`. Then launch with the chosen channels (\`email:true\` always; \`phone:true\` if they picked email + phone). Only skip this question if the user already named the channel(s) explicitly ("just emails", "get their phone numbers too", etc.).
|
|
5566
5573
|
|
|
5567
|
-
**To preview with
|
|
5574
|
+
**To preview with nothing launched on ANY host, pass \`dry_run:true\`** (or \`confirm:false\`). Either returns \`enrichable_contacts\` + the \`would_launch\` channels and launches nothing \u2014 guaranteed, regardless of host. Use this as your first call. Then surface the volume, get the user's explicit go-ahead (and channel choice, above), and re-call with \`confirm:true\` (and explicit \`email:true\`/\`phone:true\` for the channels they chose) to launch.
|
|
5568
5575
|
|
|
5569
|
-
Do NOT rely on a bare call (no \`confirm\`, no \`dry_run\`, no channels) as a "safe preview": on an elicitation-capable host it asks the user and withholds on decline (\`mode:"needs_confirmation"\`), but on a host WITHOUT elicitation (some direct/embedded callers) a bare call launches the default email
|
|
5576
|
+
Do NOT rely on a bare call (no \`confirm\`, no \`dry_run\`, no channels) as a "safe preview": on an elicitation-capable host it asks the user and withholds on decline (\`mode:"needs_confirmation"\`), but on a host WITHOUT elicitation (some direct/embedded callers) a bare call launches the default email reveal directly. If you're unsure whether the host can elicit, use \`dry_run:true\`/\`confirm:false\` for the preview. Passing \`email:true\`/\`phone:true\` (or \`confirm:true\`) always counts as consent and launches.
|
|
5570
5577
|
|
|
5571
|
-
## SHOW WHAT WILL RUN
|
|
5578
|
+
## SHOW WHAT WILL RUN
|
|
5572
5579
|
|
|
5573
|
-
**BEFORE (confirm before launching).** The discover / preview_only / dry_run modes return \`enrichable_contacts\` (the volume that would be enriched). Tell the user plainly: **"This will enrich {enrichable_contacts} contacts (email + phone reveals consume quota)."** then confirm the channels + go-ahead via \`ask_user_input_v0\` before launching. Do NOT quote an exact cost or a "credits" figure \u2014 the per-reveal rate is backend-side and enrichment is gated by quota, not a credit balance. The \`credits_remaining\` field is advisory context only; never present it as a
|
|
5580
|
+
**BEFORE (confirm before launching).** The discover / preview_only / dry_run modes return \`enrichable_contacts\` (the volume that would be enriched). Tell the user plainly: **"This will enrich {enrichable_contacts} contacts (email + phone reveals consume quota)."** then confirm the channels + go-ahead via \`ask_user_input_v0\` before launching. Do NOT quote an exact cost or a "credits" figure \u2014 the per-reveal rate is backend-side and enrichment is gated by quota, not a credit balance. The \`credits_remaining\` field is advisory context only; never present it as a gate and never refuse based on it.
|
|
5574
5581
|
|
|
5575
|
-
**AFTER
|
|
5582
|
+
**AFTER.** Do NOT invent a "credits used" figure for the run (per-run cost can't be scoped reliably \u2014 a lead's contacts mix earlier enrichments).
|
|
5576
5583
|
|
|
5577
5584
|
## AFTER LAUNCH \u2014 STAY ACTIVE UNTIL DONE
|
|
5578
5585
|
|
|
5579
|
-
When a launch returns \`mode:"launched"\` with a \`notification_id\`, the enrichment runs ASYNC on the backend \u2014 the tool returns immediately, before any email/phone is attached. **Unless the user explicitly said to start it in the background / not to wait** (e.g. "kick it off, I'll check later", "don't wait for it"), stay active and report in-turn \u2014 do NOT end your turn on the ack, and do NOT say "I'll let you know when it's done." (If the user DID ask you not to wait, honor that: hand back the \`notification_id\` and a one-line "running \u2014 you can ask any time". Only promise that completion will auto-surface via \`_meta.notifications\` when the launch returned a non-null \`notification_id\`; if \`notification_id\` is null (the nullable-backend path), say instead that you'll re-check when asked / they should ask again later \u2014 nothing surfaces automatically without a notification id. Don't force a poll loop against explicit intent.) In the default (stay-active) case: call \`leadbay_bulk_enrich_status({notification_id})\` in a loop, re-polling until the job is done (small batches typically finish in under ~2 min). Pass \`include_contacts:true\` on the read you intend to report from, so you get each lead's enriched contacts back. Note that \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), which can include contacts of OTHER roles that were already enriched in earlier runs \u2014 so **filter your report to the \`titles\` you just enriched** (match each contact's \`job_title\` to the requested titles). Don't present a pre-existing CFO/Sales email as part of this CEO/Owner/Manager run. Then \u2014 on your own, without waiting for the user to reprompt \u2014 report the enrichment: which of the just-enriched contacts now have emails / phones, and the counts from \`overall_progress\` (\`done\`/\`total\`). \`leadbay_bulk_enrich_status\` also returns \`bulk_progress.success_count\` / \`failure_count\` / \`quota_hit_count\` on the notification fast path \u2014 use those when present, but the per-lead path returns \`overall_progress\` only, so don't assume \`bulk_progress\` exists (see the status tool's COMPLETION REPORT).
|
|
5586
|
+
When a launch returns \`mode:"launched"\` with a \`notification_id\`, the enrichment runs ASYNC on the backend \u2014 the tool returns immediately, before any email/phone is attached. **Unless the user explicitly said to start it in the background / not to wait** (e.g. "kick it off, I'll check later", "don't wait for it"), stay active and report in-turn \u2014 do NOT end your turn on the ack, and do NOT say "I'll let you know when it's done." (If the user DID ask you not to wait, honor that: hand back the \`notification_id\` and a one-line "running \u2014 you can ask any time". Only promise that completion will auto-surface via \`_meta.notifications\` when the launch returned a non-null \`notification_id\`; if \`notification_id\` is null (the nullable-backend path), say instead that you'll re-check when asked / they should ask again later \u2014 nothing surfaces automatically without a notification id. Don't force a poll loop against explicit intent.) In the default (stay-active) case: call \`leadbay_bulk_enrich_status({notification_id})\` in a loop, re-polling until the job is done (small batches typically finish in under ~2 min). Pass \`include_contacts:true\` on the read you intend to report from, so you get each lead's enriched contacts back. Note that \`include_contacts\` returns each lead's FULL contact list (it fans out through \`leadbay_get_contacts\`), which can include contacts of OTHER roles that were already enriched in earlier runs \u2014 so **filter your report to the \`titles\` you just enriched** (match each contact's \`job_title\` to the requested titles). Don't present a pre-existing CFO/Sales email as part of this CEO/Owner/Manager run. Then \u2014 on your own, without waiting for the user to reprompt \u2014 report the enrichment: which of the just-enriched contacts now have emails / phones, and the counts from \`overall_progress\` (\`done\`/\`total\`). \`leadbay_bulk_enrich_status\` also returns \`bulk_progress.success_count\` / \`failure_count\` / \`quota_hit_count\` on the notification fast path \u2014 use those when present, but the per-lead path returns \`overall_progress\` only, so don't assume \`bulk_progress\` exists (see the status tool's COMPLETION REPORT).
|
|
5580
5587
|
|
|
5581
|
-
**"Done" = \`all_done:true\` OR the resolvable work has plateaued.** Keep polling while \`overall_progress.done\` is still climbing. But
|
|
5588
|
+
**"Done" = \`all_done:true\` OR the resolvable work has plateaued.** Keep polling while \`overall_progress.done\` is still climbing. But a reservation the provider never answers stays \`done:false\` \u2014 so a job can sit below 100% with \`all_done:false\` forever (a contact with no findable email does flip, to \`done:true\` with \`credits_used:0\`). A plateau is only real once the job has had time to run: do NOT declare it from the first few back-to-back reads (early on \`done\` can sit at its initial value while the backend is still spinning the job up). Give it at least ~90s\u20132 min of actual elapsed polling \u2014 space your polls out (~15\u201330s apart) rather than firing them back-to-back \u2014 and only treat the set as complete when \`overall_progress.done\` has held steady across several spaced polls over that window. Then stop polling and report what resolved, naming the ones that didn't. Key the "didn't resolve" wording off the channels the user actually requested and the returned contact fields (contacts carry \`email\` and \`phone_number\`) \u2014 a contact enriched for phone that came back with no \`phone_number\` is "no phone number found", one with no \`email\` is "no email found", email+phone that got neither is "no contact details found"; if \`quota_hit_count\` is non-zero say those were skipped because the quota window was exhausted. Do NOT hard-label every non-success as "no email found" when phone was requested. Do NOT spin indefinitely waiting for \`all_done\` on contacts the engine won't resolve, and do NOT \`ScheduleWakeup\` / defer the finished list to a later turn \u2014 deliver the resolved results in THIS reply.
|
|
5582
5589
|
|
|
5583
5590
|
The \`_meta.notifications\` push is the FALLBACK for a job launched in an EARLIER turn or across an MCP restart \u2014 it is NOT a reason to stop early on a job you just launched this turn.
|
|
5584
5591
|
|
|
@@ -5720,8 +5727,8 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
5720
5727
|
|
|
5721
5728
|
3-col table of delivered leads in returned order: col 1 = 10-segment fit
|
|
5722
5729
|
bar + linked company \xB7 location \xB7 size; col 2 = why-fits \u226420 words; col 3
|
|
5723
|
-
= contact +
|
|
5724
|
-
(matched/examined/delivered/stop reason
|
|
5730
|
+
= contact + found channels. ALWAYS close with the honest funnel line
|
|
5731
|
+
(matched/examined/delivered/stop reason) \u2014 especially on 0
|
|
5725
5732
|
delivered. Full algorithm below.
|
|
5726
5733
|
|
|
5727
5734
|
---
|
|
@@ -5729,19 +5736,19 @@ delivered. Full algorithm below.
|
|
|
5729
5736
|
Submit a net-new lead search: the backend matches an ICP seed against the full
|
|
5730
5737
|
company universe, applies hard filters, skips what the org already knows
|
|
5731
5738
|
(\`novelty: org\`), optionally qualifies against the org's own intelligence
|
|
5732
|
-
(questions, tags, ideal buyer profile \u2014 frozen at submit), and optionally
|
|
5739
|
+
(questions, tags, ideal buyer profile \u2014 frozen at submit), and optionally reveals
|
|
5733
5740
|
contact channels. Polls up to \`wait_seconds\` (default 45); a longer job returns
|
|
5734
5741
|
\`still_running\` + \`next_poll\` \u2014 hand off to \`leadbay_lead_job_status\`. Jobs run
|
|
5735
5742
|
\u226430 min, results kept 30 days.
|
|
5736
5743
|
|
|
5737
|
-
**Free vs
|
|
5744
|
+
**Free vs usage quota \u2014 never use quota silently.** Default (\`qualify: false\`,
|
|
5738
5745
|
\`channels: []\`) is FREE: company profile + fit score + cached research +
|
|
5739
|
-
contact identity.
|
|
5740
|
-
|
|
5741
|
-
|
|
5746
|
+
contact identity. \`qualify: true\` (per candidate EXAMINED, capped by
|
|
5747
|
+
\`exploration_cap\`/\`max_cost\`) and \`channels\` (only when a value is found) draw
|
|
5748
|
+
on the org's usage quota; nothing is invoiced. Enforced in code: such a call is WITHHELD unless it
|
|
5742
5749
|
carries \`confirm: true\` \u2014 nothing is submitted and you get
|
|
5743
5750
|
\`mode: "needs_confirmation"\` with a real quote to show the user. Re-call with
|
|
5744
|
-
\`confirm: true\` on their go-ahead ("
|
|
5751
|
+
\`confirm: true\` on their go-ahead ("go ahead / get their emails" counts).
|
|
5745
5752
|
\`confirm: false\` vetoes. Free needs no consent. **Preview free first** \u2014
|
|
5746
5753
|
reshaping an off-profile seed is free, exploring it with \`qualify: true\` is
|
|
5747
5754
|
not.
|
|
@@ -5749,7 +5756,7 @@ not.
|
|
|
5749
5756
|
**Ad-hoc exclusions ("no chains") are enforced by NO tier** \u2014 \`filters\` has no
|
|
5750
5757
|
exclusion key, and \`qualify\` scores against the org's FROZEN questions and IBP,
|
|
5751
5758
|
which need not mention chains; the seed's inverse only shifts ranking.
|
|
5752
|
-
Violators can survive,
|
|
5759
|
+
Violators can survive, use quota and be delivered \u2014 post-filter them yourself
|
|
5753
5760
|
and say the tier didn't enforce it. Durable enforcement \u2192
|
|
5754
5761
|
\`leadbay_refine_prompt\`.
|
|
5755
5762
|
|
|
@@ -5796,9 +5803,9 @@ measured:
|
|
|
5796
5803
|
|
|
5797
5804
|
**Parameter notes**
|
|
5798
5805
|
- \`request_id\` (REQUIRED) is the retry contract: SAME value retrying the same
|
|
5799
|
-
ask (same live job, no double
|
|
5806
|
+
ask (same live job, no double launch); NEW for a changed ask. Derive from ask
|
|
5800
5807
|
+ archetype + date: \`gyms-dallas-2026-07-28\`.
|
|
5801
|
-
- Never lower \`min_ai_score\` together with \`channels\` \u2014 that
|
|
5808
|
+
- Never lower \`min_ai_score\` together with \`channels\` \u2014 that reveals emails for
|
|
5802
5809
|
leads the AI just scored as junk.
|
|
5803
5810
|
- \`count\` \u2264 50; \u22643 active jobs/org; \u226410 submits/hour (429 + Retry-After \u2014
|
|
5804
5811
|
wait, don't hammer).
|
|
@@ -5838,7 +5845,7 @@ when nothing was delivered.
|
|
|
5838
5845
|
**Column 3 \u2014 Contact**
|
|
5839
5846
|
|
|
5840
5847
|
- \`[Name](linkedin) \xB7 role\` (linked name mandatory when a LinkedIn URL
|
|
5841
|
-
exists; plain name otherwise). Below it, the
|
|
5848
|
+
exists; plain name otherwise). Below it, the FOUND channels only:
|
|
5842
5849
|
\`\u2709 value\` / \`\u260E value\` inline as plain text (they auto-linkify).
|
|
5843
5850
|
- Channel statuses: \`delivered\` \u2192 show value; \`already_owned\` \u2192 value +
|
|
5844
5851
|
*(already yours)*; \`masked\` \u2192 "on file \u2014 reveal via channels";
|
|
@@ -5849,16 +5856,17 @@ when nothing was delivered.
|
|
|
5849
5856
|
|
|
5850
5857
|
**The funnel line (mandatory, after the table):**
|
|
5851
5858
|
|
|
5852
|
-
One short line narrating the delivery honestly, from \`funnel\` +
|
|
5859
|
+
One short line narrating the delivery honestly, from \`funnel\` +
|
|
5853
5860
|
\`explain.scope_notes\`:
|
|
5854
5861
|
|
|
5855
5862
|
> Matched N \xB7 examined E \xB7 qualified Q \xB7 disqualified D \u2192 **delivered X of
|
|
5856
|
-
> the Y asked** \xB7 stopped: <stop_reason in plain words
|
|
5863
|
+
> the Y asked** \xB7 stopped: <stop_reason in plain words>.
|
|
5857
5864
|
|
|
5858
|
-
**
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
|
|
5865
|
+
**No money, anywhere.** \`cost.*\`, \`estimated_cost.max\` and quotes are internal
|
|
5866
|
+
usage units. Never render them, never convert them to a currency, never call
|
|
5867
|
+
them a charge: the user's plan or top-up covers this work, and a price reads as
|
|
5868
|
+
a bill. If the user asks what a job used, show \`leadbay_account_status\`'s quota
|
|
5869
|
+
windows.
|
|
5862
5870
|
|
|
5863
5871
|
"of the Y asked" needs \`summary.items_requested\`, which submits carry but a
|
|
5864
5872
|
later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered
|
|
@@ -5866,7 +5874,7 @@ X** and stop \u2014 never back-fill Y from \`matched\`/\`examined\` (they count
|
|
|
5866
5874
|
candidates), never guess it.
|
|
5867
5875
|
|
|
5868
5876
|
Plain-word stop reasons: \`target_reached\` \u2192 omit (success), \`pool_exhausted\` \u2192
|
|
5869
|
-
"ran out of matching candidates", \`max_cost\` \u2192 "hit the
|
|
5877
|
+
"ran out of matching candidates", \`max_cost\` \u2192 "hit the job's usage cap", \`quota\` \u2192
|
|
5870
5878
|
"hit an org quota", \`time_budget\` \u2192 "hit the 30-min time budget".
|
|
5871
5879
|
|
|
5872
5880
|
**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give
|
|
@@ -5891,7 +5899,7 @@ table \`Ref \u2192 Outcome\` translating \`status_reason\` to plain words:
|
|
|
5891
5899
|
\`low_confidence_identity\` \u2192 "couldn't safely match \u2014 check \`resolution.alternatives\`",
|
|
5892
5900
|
\`no_matching_contact\` \u2192 "no contact with the requested title",
|
|
5893
5901
|
\`disqualified\` \u2192 "evaluated: does not fit" (evidence is in the item when owned),
|
|
5894
|
-
\`enrichment_failed\` \u2192 "channel could not be sourced (
|
|
5902
|
+
\`enrichment_failed\` \u2192 "channel could not be sourced (no quota used)".
|
|
5895
5903
|
|
|
5896
5904
|
**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer
|
|
5897
5905
|
\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest.
|
|
@@ -5940,10 +5948,10 @@ Pick the 2-3 options that match what actually happened \u2014 never all seven:
|
|
|
5940
5948
|
| Observation | Suggest | Calls |
|
|
5941
5949
|
|---|---|---|
|
|
5942
5950
|
| Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
|
|
5943
|
-
| Free run delivered on-profile leads | "Qualify these N against your criteria (
|
|
5951
|
+
| Free run delivered on-profile leads | "Qualify these N against your criteria (uses quota \u2014 \`dry_run\` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) |
|
|
5944
5952
|
| Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
|
|
5945
5953
|
| Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
|
|
5946
|
-
| Stopped at
|
|
5954
|
+
| Stopped at the job's usage cap (\`stop_reason: max_cost\`) | "Raise the job's cap and get the remaining N" \u2014 no amount, no currency | 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) |
|
|
5947
5955
|
| 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 |
|
|
5948
5956
|
| Stopped on org quota and the user does not want to wait | "Top up to finish this run" | leadbay_create_topup_link |
|
|
5949
5957
|
| User wants these tracked in Leadbay | "Add the keepers to a campaign" | leadbay_create_campaign / leadbay_add_leads_to_campaign |
|
|
@@ -6433,7 +6441,7 @@ Per step: \`gate_label\` / \`gate_description\` are the widget's forward option,
|
|
|
6433
6441
|
| 1 | Check my account | \`leadbay_account_status\` (no args) |
|
|
6434
6442
|
| 2 | Pull today's leads | \`leadbay_pull_leads\` (no args) |
|
|
6435
6443
|
| 3 | Draft the first email | \`leadbay_prepare_outreach\` \u2014 \`leadId\` ONLY, never \`enrich\` |
|
|
6436
|
-
| 4 | Find who to email | \`leadbay_enrich_titles\` \u2014 free preview, then a
|
|
6444
|
+
| 4 | Find who to email | \`leadbay_enrich_titles\` \u2014 free preview, then a reveal on consent |
|
|
6437
6445
|
|
|
6438
6446
|
Steps 1, 2 and 3 carry \`branches[]\`, and steps 3 and 4 carry \`spend\` (+ \`quota_note\` on 4). Every step also carries \`explain\` (say this BEFORE firing) and \`next_steps\` (\`{question, options[]}\` \u2014 already the widget's shape, map it verbatim).
|
|
6439
6447
|
|
|
@@ -6445,9 +6453,9 @@ The manifest also carries **\`keep_going\`**: the closing cheat-sheet of *what y
|
|
|
6445
6453
|
|
|
6446
6454
|
**Step 1 shows the real account, and is silent about two things.** The click is labelled *check my account status*, so deliver it: user + org, then the **full quota windows** the way the web app renders them \u2014 Daily / Weekly / Monthly with a \`\u25B0\u25B1\` gauge, % used, $ spent against the cap, resets countdown, and the per-resource breakdown. Never raw "credits". But apply the silence gate first: when \`quota\` is null, \`quota_error\` is set, or the org has \`unlimited_credits\`, say **nothing** about quota \u2014 never mention a 401, never suggest logging in again (the token is fine, the same response just read their account), and never announce "unlimited". And **never volunteer the lens**: the response withholds it unless the user asked, so there is nothing to report and no other tool to reach for. Both are pinned regressions (WORKFLOWS #30 / #31).
|
|
6447
6455
|
|
|
6448
|
-
**Step 3 drafts, and
|
|
6456
|
+
**Step 3 drafts, and uses no quota.** Call \`leadbay_prepare_outreach\` with \`leadId\` alone \u2014 **never \`enrich: true\`**, which launches a contact reveal off the back of a *draft* click. \`recommended_contact\` returns with \`email\`/\`phone\` null; that is expected, and it is the hook for step 4. Render through \`message_compose_v1\` (2\u20133 strategy-labelled variants), address it to the job TITLE \u2014 no name exists yet, and inventing one is fabrication \u2014 and never send it or offer to.
|
|
6449
6457
|
|
|
6450
|
-
**Step 4 runs in two beats \u2014 free first,
|
|
6458
|
+
**Step 4 runs in two beats \u2014 free first, the reveal only on consent.** Scoped to the ONE lead step 3 drafted for. Beat 1 omits \`titles\` and returns \`mode:"discover"\`, the free list of job titles at that company; say plainly that nothing has run yet. Beat 2 names the title the draft is addressed to, says BEFORE they decide that revealing one contact uses a little of their plan's quota (no amount, no price), and only on confirmation calls again with \`titles\` + \`confirm:true\` + \`email:true\` \u2014 polled via \`leadbay_bulk_enrich_status\` until done, reporting only what actually resolved. The gate click bought the free look, not the reveal: never launch without an explicit confirm.
|
|
6451
6459
|
|
|
6452
6460
|
## Empty first batch is normal, not an error
|
|
6453
6461
|
|
|
@@ -6484,7 +6492,7 @@ WHEN NOT TO USE: discovery (use leadbay_pull_leads); single-lead deep dive (use
|
|
|
6484
6492
|
Leadbay has no cancel. Once \`leadbay_enrich_titles\`, \`leadbay_bulk_qualify_leads\`,
|
|
6485
6493
|
\`leadbay_import_leads\` or \`leadbay_import_and_qualify\` has returned a launched or
|
|
6486
6494
|
running result, that work is queued on Leadbay and runs to completion, and the
|
|
6487
|
-
quota it
|
|
6495
|
+
quota it uses is already committed. A discovery, preview or \`dry_run\` result
|
|
6488
6496
|
launched nothing and is not covered here.
|
|
6489
6497
|
|
|
6490
6498
|
The user cancelling in the chat, a request timeout, or a closed stream stops YOUR
|
|
@@ -6492,7 +6500,7 @@ waiting, never the job. \`cancelled: true\` means we stopped watching, not that
|
|
|
6492
6500
|
work stopped. What to do next depends on what you are holding:
|
|
6493
6501
|
|
|
6494
6502
|
- **A handle.** Poll the status tool with it, and do not launch the work that
|
|
6495
|
-
handle covers a second time \u2014 that
|
|
6503
|
+
handle covers a second time \u2014 that uses the quota again on the same rows.
|
|
6496
6504
|
\`leadbay_import_status\` takes \`importIds\`, so pass the values of \`import_ids\`
|
|
6497
6505
|
under that name. A qualification started by \`leadbay_import_and_qualify\` has no
|
|
6498
6506
|
notification of its own: resume it with
|
|
@@ -6506,7 +6514,7 @@ work stopped. What to do next depends on what you are holding:
|
|
|
6506
6514
|
finished. Calling the same tool again with the same arguments will usually hand
|
|
6507
6515
|
back the job already launched rather than starting a second one, but that guard
|
|
6508
6516
|
is in-memory, five minutes, and per process, so it is best-effort \u2014 say what you
|
|
6509
|
-
are about to re-run before you
|
|
6517
|
+
are about to re-run before you use the user's quota on it.
|
|
6510
6518
|
|
|
6511
6519
|
|
|
6512
6520
|
Budgets: \`total_budget_ms\` caps wall-clock; \`per_lead_budget_ms\` caps each lead's poll. For short transport timeouts, pass \`wait_for_completion:false\` and poll \`leadbay_import_status\`. Outputs \`qualified[]\`, \`still_running[]\`, \`not_imported[]\`, plus the ids that resume it: \`lead_ids\` + \`lens_id\` for leadbay_qualify_status, \`import_ids\` for leadbay_import_status. There is no qualification \`notification_id\` \u2014 the qualify phase runs per-lead, so no job notification exists; \`notification_ids[]\` are the file-import ones. Idempotent within a 5-min window. \`dry_run:'preview'\` returns mapping hints + custom-field candidates without importing.
|
|
@@ -6729,7 +6737,7 @@ a launcher only for a subset that never started, never for the whole batch:
|
|
|
6729
6737
|
that are the ones that never started. Only then call
|
|
6730
6738
|
\`leadbay_bulk_qualify_leads({leadIds, lensId})\` for exactly those ids. A lead
|
|
6731
6739
|
that is merely slow looks identical to one that never launched over a few
|
|
6732
|
-
polls, and re-launching it
|
|
6740
|
+
polls, and re-launching it uses the user's quota twice \u2014 when unsure, tell the user
|
|
6733
6741
|
rather than guess.
|
|
6734
6742
|
|
|
6735
6743
|
|
|
@@ -6796,18 +6804,18 @@ WHEN NOT TO USE: from agent flow \u2014 leadbay_enrich_titles handles selection
|
|
|
6796
6804
|
## A launched job cannot be stopped, and this tool has no retry guard
|
|
6797
6805
|
|
|
6798
6806
|
Leadbay has no cancel. Once this call returns having actually launched, the work
|
|
6799
|
-
is queued on Leadbay and runs to completion, and the quota it
|
|
6800
|
-
committed. A \`dry_run\` result reached no backend and
|
|
6807
|
+
is queued on Leadbay and runs to completion, and the quota it uses is already
|
|
6808
|
+
committed. A \`dry_run\` result reached no backend and used nothing. The user
|
|
6801
6809
|
cancelling in the chat, a request timeout, or a closed stream stops YOUR waiting,
|
|
6802
6810
|
never the job.
|
|
6803
6811
|
|
|
6804
6812
|
Unlike the composite launchers, this tool has **no double-launch guard**: calling
|
|
6805
|
-
it again always issues a new
|
|
6813
|
+
it again always issues a new launch that uses quota again, even seconds later with identical
|
|
6806
6814
|
arguments. So when a call returns nothing at all, do not simply retry. Read the
|
|
6807
6815
|
record back first \u2014 \`leadbay_research_lead_by_id\` or \`leadbay_get_contacts\` for a
|
|
6808
6816
|
lead, \`leadbay_account_status\` for background work that has since finished \u2014 to
|
|
6809
6817
|
see whether the launch already landed, and tell the user what you are about to
|
|
6810
|
-
|
|
6818
|
+
run before running it again.
|
|
6811
6819
|
|
|
6812
6820
|
|
|
6813
6821
|
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\`.
|
|
@@ -6832,14 +6840,16 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
6832
6840
|
|
|
6833
6841
|
Terminal job -> render the full delivery per the lead-delivery table +
|
|
6834
6842
|
honest funnel line. Still running -> one progress line (examined /
|
|
6835
|
-
delivered
|
|
6843
|
+
delivered so far) and offer to check again in ~1 min. Never
|
|
6836
6844
|
render UUIDs or cursors.
|
|
6837
6845
|
|
|
6838
6846
|
---
|
|
6839
6847
|
|
|
6840
6848
|
Cumulative snapshot of a lead-delivery job: state, funnel counters, every
|
|
6841
|
-
item emitted so far (
|
|
6842
|
-
|
|
6849
|
+
item emitted so far (compact lead payloads for delivered/degraded, with
|
|
6850
|
+
one reason and the freshest signals; full evidence via
|
|
6851
|
+
\`leadbay_research_lead_by_id\`; honest status_reason for skipped), internal
|
|
6852
|
+
usage counters (never render them), and the \`explain\` block
|
|
6843
6853
|
(basis, seed strategy, scope notes). Items are immutable once emitted \u2014
|
|
6844
6854
|
polling never re-reads live data, so numbers only ever grow.
|
|
6845
6855
|
|
|
@@ -6848,7 +6858,7 @@ completion when the user asked for results "in this reply". \`since\` (from a
|
|
|
6848
6858
|
prior poll's \`next_since\`) pages only the new items. Jobs terminalize
|
|
6849
6859
|
server-side: past the 30-min wall clock a job reads \`completed_partial\`
|
|
6850
6860
|
(time budget), past 30 days \`expired\` (items no longer listed \u2014 re-read
|
|
6851
|
-
|
|
6861
|
+
delivered leads via leadbay_qualify_leads \`prior_deliveries\`). A 404 means
|
|
6852
6862
|
unknown job or another org's job.
|
|
6853
6863
|
|
|
6854
6864
|
---
|
|
@@ -6883,7 +6893,7 @@ when nothing was delivered.
|
|
|
6883
6893
|
**Column 3 \u2014 Contact**
|
|
6884
6894
|
|
|
6885
6895
|
- \`[Name](linkedin) \xB7 role\` (linked name mandatory when a LinkedIn URL
|
|
6886
|
-
exists; plain name otherwise). Below it, the
|
|
6896
|
+
exists; plain name otherwise). Below it, the FOUND channels only:
|
|
6887
6897
|
\`\u2709 value\` / \`\u260E value\` inline as plain text (they auto-linkify).
|
|
6888
6898
|
- Channel statuses: \`delivered\` \u2192 show value; \`already_owned\` \u2192 value +
|
|
6889
6899
|
*(already yours)*; \`masked\` \u2192 "on file \u2014 reveal via channels";
|
|
@@ -6894,16 +6904,17 @@ when nothing was delivered.
|
|
|
6894
6904
|
|
|
6895
6905
|
**The funnel line (mandatory, after the table):**
|
|
6896
6906
|
|
|
6897
|
-
One short line narrating the delivery honestly, from \`funnel\` +
|
|
6907
|
+
One short line narrating the delivery honestly, from \`funnel\` +
|
|
6898
6908
|
\`explain.scope_notes\`:
|
|
6899
6909
|
|
|
6900
6910
|
> Matched N \xB7 examined E \xB7 qualified Q \xB7 disqualified D \u2192 **delivered X of
|
|
6901
|
-
> the Y asked** \xB7 stopped: <stop_reason in plain words
|
|
6911
|
+
> the Y asked** \xB7 stopped: <stop_reason in plain words>.
|
|
6902
6912
|
|
|
6903
|
-
**
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6913
|
+
**No money, anywhere.** \`cost.*\`, \`estimated_cost.max\` and quotes are internal
|
|
6914
|
+
usage units. Never render them, never convert them to a currency, never call
|
|
6915
|
+
them a charge: the user's plan or top-up covers this work, and a price reads as
|
|
6916
|
+
a bill. If the user asks what a job used, show \`leadbay_account_status\`'s quota
|
|
6917
|
+
windows.
|
|
6907
6918
|
|
|
6908
6919
|
"of the Y asked" needs \`summary.items_requested\`, which submits carry but a
|
|
6909
6920
|
later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered
|
|
@@ -6911,7 +6922,7 @@ X** and stop \u2014 never back-fill Y from \`matched\`/\`examined\` (they count
|
|
|
6911
6922
|
candidates), never guess it.
|
|
6912
6923
|
|
|
6913
6924
|
Plain-word stop reasons: \`target_reached\` \u2192 omit (success), \`pool_exhausted\` \u2192
|
|
6914
|
-
"ran out of matching candidates", \`max_cost\` \u2192 "hit the
|
|
6925
|
+
"ran out of matching candidates", \`max_cost\` \u2192 "hit the job's usage cap", \`quota\` \u2192
|
|
6915
6926
|
"hit an org quota", \`time_budget\` \u2192 "hit the 30-min time budget".
|
|
6916
6927
|
|
|
6917
6928
|
**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give
|
|
@@ -6936,7 +6947,7 @@ table \`Ref \u2192 Outcome\` translating \`status_reason\` to plain words:
|
|
|
6936
6947
|
\`low_confidence_identity\` \u2192 "couldn't safely match \u2014 check \`resolution.alternatives\`",
|
|
6937
6948
|
\`no_matching_contact\` \u2192 "no contact with the requested title",
|
|
6938
6949
|
\`disqualified\` \u2192 "evaluated: does not fit" (evidence is in the item when owned),
|
|
6939
|
-
\`enrichment_failed\` \u2192 "channel could not be sourced (
|
|
6950
|
+
\`enrichment_failed\` \u2192 "channel could not be sourced (no quota used)".
|
|
6940
6951
|
|
|
6941
6952
|
**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer
|
|
6942
6953
|
\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest.
|
|
@@ -7003,7 +7014,7 @@ is a status tool, keep it terse:
|
|
|
7003
7014
|
|---|---|---|
|
|
7004
7015
|
| Still running | "Keep waiting (~1 min) or leave it \u2014 results are kept 30 days" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
|
|
7005
7016
|
| Terminal (completed / partial / failed) | Render the delivery per the RENDERING block, then offer the matching find_new_leads / qualify_leads NEXT STEPS | \u2014 |
|
|
7006
|
-
| \`expired\` (past the 30-day window) | "Re-read the
|
|
7017
|
+
| \`expired\` (past the 30-day window) | "Re-read the delivered 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}) |
|
|
7007
7018
|
`;
|
|
7008
7019
|
var leadbay_like_lead = `## WHEN TO USE
|
|
7009
7020
|
|
|
@@ -7679,9 +7690,9 @@ Offer 2\u20133 follow-ups. Choose based on enrichment state + available channels
|
|
|
7679
7690
|
| After a successful exchange | "Update qualification answers based on what you learned" | leadbay_answer_clarification |
|
|
7680
7691
|
The "log outreach" step is the most-important follow-up \u2014 it closes the loop and populates history for the next \`leadbay_prepare_outreach\` call. Detect intent from natural language: "I sent the email", "she didn't pick up", "left a voicemail", "they responded yes/no", etc.
|
|
7681
7692
|
`;
|
|
7682
|
-
var leadbay_preview_bulk_enrichment = `Preview a bulk
|
|
7693
|
+
var leadbay_preview_bulk_enrichment = `Preview a bulk enrichment given a set of job titles applied to the current selection. Returns \`{selected_leads, enriched_contacts, enrichable_contacts, title_suggestions, auto_included_titles, previously_enriched_titles}\`. \`previously_enriched_titles\` is a newer field (in prod soon) \u2014 when present, the agent can recommend repeating those titles for new leads.
|
|
7683
7694
|
|
|
7684
|
-
WHEN TO USE: between selecting leads and launching, to know
|
|
7695
|
+
WHEN TO USE: between selecting leads and launching, to know how many contacts the enrichment will reveal.
|
|
7685
7696
|
|
|
7686
7697
|
WHEN NOT TO USE: from agent flow \u2014 leadbay_enrich_titles wraps preview + launch with the right safety checks.
|
|
7687
7698
|
`;
|
|
@@ -8076,18 +8087,18 @@ WHEN NOT TO USE: as the agent's bulk-qualify path \u2014 use leadbay_bulk_qualif
|
|
|
8076
8087
|
## A launched job cannot be stopped, and this tool has no retry guard
|
|
8077
8088
|
|
|
8078
8089
|
Leadbay has no cancel. Once this call returns having actually launched, the work
|
|
8079
|
-
is queued on Leadbay and runs to completion, and the quota it
|
|
8080
|
-
committed. A \`dry_run\` result reached no backend and
|
|
8090
|
+
is queued on Leadbay and runs to completion, and the quota it uses is already
|
|
8091
|
+
committed. A \`dry_run\` result reached no backend and used nothing. The user
|
|
8081
8092
|
cancelling in the chat, a request timeout, or a closed stream stops YOUR waiting,
|
|
8082
8093
|
never the job.
|
|
8083
8094
|
|
|
8084
8095
|
Unlike the composite launchers, this tool has **no double-launch guard**: calling
|
|
8085
|
-
it again always issues a new
|
|
8096
|
+
it again always issues a new launch that uses quota again, even seconds later with identical
|
|
8086
8097
|
arguments. So when a call returns nothing at all, do not simply retry. Read the
|
|
8087
8098
|
record back first \u2014 \`leadbay_research_lead_by_id\` or \`leadbay_get_contacts\` for a
|
|
8088
8099
|
lead, \`leadbay_account_status\` for background work that has since finished \u2014 to
|
|
8089
8100
|
see whether the launch already landed, and tell the user what you are about to
|
|
8090
|
-
|
|
8101
|
+
run before running it again.
|
|
8091
8102
|
|
|
8092
8103
|
|
|
8093
8104
|
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\`.
|
|
@@ -8115,7 +8126,7 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
8115
8126
|
3-col table for delivered items (fit bar + company / why-fits \u226420 words /
|
|
8116
8127
|
contact + channels) in returned order, then a compact Ref \u2192 Outcome table
|
|
8117
8128
|
for skipped refs (not_in_universe, low_confidence_identity, ... in plain
|
|
8118
|
-
words), then the honest funnel
|
|
8129
|
+
words), then the honest funnel line. Full algorithm below.
|
|
8119
8130
|
|
|
8120
8131
|
---
|
|
8121
8132
|
|
|
@@ -8130,29 +8141,29 @@ job; this tool polls up to \`wait_seconds\` (default 45) and hands off to
|
|
|
8130
8141
|
**Refs are flexible; outcomes are per-item.** \`lead_refs\` accepts any mix of
|
|
8131
8142
|
\`lead_id\`, \`website\`, \`name\`(+\`location\`), or a stable \`contact_id\` from a
|
|
8132
8143
|
prior result (enrichment then targets exactly that person, never a re-match).
|
|
8133
|
-
\`prior_deliveries\` expands past MCP deliveries into refs \u2014
|
|
8144
|
+
\`prior_deliveries\` expands past MCP deliveries into refs \u2014 delivered leads stay
|
|
8134
8145
|
re-readable this way even after the 30-day result window. Duplicates collapse.
|
|
8135
8146
|
A ref that can't be served comes back \`skipped\` with an honest
|
|
8136
8147
|
\`status_reason\` (\`not_in_universe\`, \`low_confidence_identity\` with the
|
|
8137
8148
|
\`resolution.alternatives\` to choose from, \`no_matching_contact\`, ...) \u2014 that
|
|
8138
|
-
is an ANSWER about the ref, not an error, and it
|
|
8149
|
+
is an ANSWER about the ref, not an error, and it uses no quota.
|
|
8139
8150
|
|
|
8140
8151
|
**Disqualified \u2260 dropped.** Companies the org owns that fail qualification
|
|
8141
8152
|
are DELIVERED with their negative evidence (question verdicts, tag misses,
|
|
8142
8153
|
IBP reasoning) \u2014 "here's why to skip this account" is a deliverable.
|
|
8143
8154
|
|
|
8144
|
-
**
|
|
8145
|
-
\`qualify: true\` (the default)
|
|
8146
|
-
research+scoring \u2014 but repeat calls reuse every fresh cached stage
|
|
8147
|
-
(\`from_cache\` flags on the items) and converge to near-zero
|
|
8148
|
-
|
|
8149
|
-
|
|
8155
|
+
**Usage quota \u2014 never use it silently.** Resolution and identity are free.
|
|
8156
|
+
\`qualify: true\` (the default) draws on the org's usage quota for each lead
|
|
8157
|
+
needing FRESH research+scoring \u2014 but repeat calls reuse every fresh cached stage
|
|
8158
|
+
(\`from_cache\` flags on the items) and converge to near-zero. \`channels\` draw on
|
|
8159
|
+
it only when a verified email / phone is found; \`already_owned\` values are
|
|
8160
|
+
free. Nothing is invoiced.
|
|
8150
8161
|
|
|
8151
|
-
The gate is enforced in code, not just here: a
|
|
8162
|
+
The gate is enforced in code, not just here: a call that uses quota (\`qualify\` left at
|
|
8152
8163
|
its default or set true, and/or any \`channels\`) is WITHHELD unless it carries
|
|
8153
8164
|
\`confirm: true\`. Without it the tool submits nothing and returns
|
|
8154
8165
|
\`mode: "needs_confirmation"\` with a real backend quote \u2014 show that quote to
|
|
8155
|
-
the user, get the go-ahead (an explicit "
|
|
8166
|
+
the user, get the go-ahead (an explicit "go ahead / get their emails" in their
|
|
8156
8167
|
message counts), then re-call with \`confirm: true\`. \`confirm: false\` is a
|
|
8157
8168
|
veto: nothing is submitted and no quote round-trip is made. A fully FREE
|
|
8158
8169
|
call (\`qualify: false\`, no \`channels\`) needs no \`confirm\` and passes straight
|
|
@@ -8193,7 +8204,7 @@ when nothing was delivered.
|
|
|
8193
8204
|
**Column 3 \u2014 Contact**
|
|
8194
8205
|
|
|
8195
8206
|
- \`[Name](linkedin) \xB7 role\` (linked name mandatory when a LinkedIn URL
|
|
8196
|
-
exists; plain name otherwise). Below it, the
|
|
8207
|
+
exists; plain name otherwise). Below it, the FOUND channels only:
|
|
8197
8208
|
\`\u2709 value\` / \`\u260E value\` inline as plain text (they auto-linkify).
|
|
8198
8209
|
- Channel statuses: \`delivered\` \u2192 show value; \`already_owned\` \u2192 value +
|
|
8199
8210
|
*(already yours)*; \`masked\` \u2192 "on file \u2014 reveal via channels";
|
|
@@ -8204,16 +8215,17 @@ when nothing was delivered.
|
|
|
8204
8215
|
|
|
8205
8216
|
**The funnel line (mandatory, after the table):**
|
|
8206
8217
|
|
|
8207
|
-
One short line narrating the delivery honestly, from \`funnel\` +
|
|
8218
|
+
One short line narrating the delivery honestly, from \`funnel\` +
|
|
8208
8219
|
\`explain.scope_notes\`:
|
|
8209
8220
|
|
|
8210
8221
|
> Matched N \xB7 examined E \xB7 qualified Q \xB7 disqualified D \u2192 **delivered X of
|
|
8211
|
-
> the Y asked** \xB7 stopped: <stop_reason in plain words
|
|
8222
|
+
> the Y asked** \xB7 stopped: <stop_reason in plain words>.
|
|
8212
8223
|
|
|
8213
|
-
**
|
|
8214
|
-
|
|
8215
|
-
|
|
8216
|
-
|
|
8224
|
+
**No money, anywhere.** \`cost.*\`, \`estimated_cost.max\` and quotes are internal
|
|
8225
|
+
usage units. Never render them, never convert them to a currency, never call
|
|
8226
|
+
them a charge: the user's plan or top-up covers this work, and a price reads as
|
|
8227
|
+
a bill. If the user asks what a job used, show \`leadbay_account_status\`'s quota
|
|
8228
|
+
windows.
|
|
8217
8229
|
|
|
8218
8230
|
"of the Y asked" needs \`summary.items_requested\`, which submits carry but a
|
|
8219
8231
|
later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered
|
|
@@ -8221,7 +8233,7 @@ X** and stop \u2014 never back-fill Y from \`matched\`/\`examined\` (they count
|
|
|
8221
8233
|
candidates), never guess it.
|
|
8222
8234
|
|
|
8223
8235
|
Plain-word stop reasons: \`target_reached\` \u2192 omit (success), \`pool_exhausted\` \u2192
|
|
8224
|
-
"ran out of matching candidates", \`max_cost\` \u2192 "hit the
|
|
8236
|
+
"ran out of matching candidates", \`max_cost\` \u2192 "hit the job's usage cap", \`quota\` \u2192
|
|
8225
8237
|
"hit an org quota", \`time_budget\` \u2192 "hit the 30-min time budget".
|
|
8226
8238
|
|
|
8227
8239
|
**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give
|
|
@@ -8246,7 +8258,7 @@ table \`Ref \u2192 Outcome\` translating \`status_reason\` to plain words:
|
|
|
8246
8258
|
\`low_confidence_identity\` \u2192 "couldn't safely match \u2014 check \`resolution.alternatives\`",
|
|
8247
8259
|
\`no_matching_contact\` \u2192 "no contact with the requested title",
|
|
8248
8260
|
\`disqualified\` \u2192 "evaluated: does not fit" (evidence is in the item when owned),
|
|
8249
|
-
\`enrichment_failed\` \u2192 "channel could not be sourced (
|
|
8261
|
+
\`enrichment_failed\` \u2192 "channel could not be sourced (no quota used)".
|
|
8250
8262
|
|
|
8251
8263
|
**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer
|
|
8252
8264
|
\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest.
|
|
@@ -8314,7 +8326,7 @@ Pick the 2-3 options that match what actually happened:
|
|
|
8314
8326
|
| Fit leads with contacts delivered | "Draft outreach for the qualified ones" | leadbay_prepare_outreach |
|
|
8315
8327
|
| Items skipped \`not_in_universe\` | "Import those companies first, then re-qualify" | leadbay_import_leads \u2192 leadbay_qualify_leads |
|
|
8316
8328
|
| Items skipped \`low_confidence_identity\` | "Pick the right match" (show \`resolution.alternatives\`) | leadbay_qualify_leads with the chosen lead_id |
|
|
8317
|
-
| Contacts delivered without channels | "
|
|
8329
|
+
| Contacts delivered without channels | "Get verified emails/phones for the keepers (uses quota \u2014 say so first)" | leadbay_qualify_leads(lead_refs with contact_id, channels) |
|
|
8318
8330
|
| Disqualified with evidence | "Review why \u2014 adjust qualification questions if the criteria are off" | leadbay_get_qualification_questions |
|
|
8319
8331
|
`;
|
|
8320
8332
|
var leadbay_qualify_status = `Retrieve the current state of a bulk_qualify_leads or import_and_qualify launch. Which ids to pass depends on which tool launched it, because only one of them creates a qualification job on the backend:
|
|
@@ -8360,7 +8372,7 @@ a launcher only for a subset that never started, never for the whole batch:
|
|
|
8360
8372
|
that are the ones that never started. Only then call
|
|
8361
8373
|
\`leadbay_bulk_qualify_leads({leadIds, lensId})\` for exactly those ids. A lead
|
|
8362
8374
|
that is merely slow looks identical to one that never launched over a few
|
|
8363
|
-
polls, and re-launching it
|
|
8375
|
+
polls, and re-launching it uses the user's quota twice \u2014 when unsure, tell the user
|
|
8364
8376
|
rather than guess.
|
|
8365
8377
|
`;
|
|
8366
8378
|
var leadbay_recall_ordered_titles = `Show job titles the org has previously enriched, so the agent can repeat the same titles for new leads (or skip already-saturated ones). Two implementation paths: (1) PREFERRED \u2014 a selection-scoped preview call that reads \`previously_enriched_titles\` from the backend (newer prod field). (2) FALLBACK \u2014 live aggregation across each lead's enriched contacts. The composite picks transparently.
|
|
@@ -9912,9 +9924,9 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
9912
9924
|
|
|
9913
9925
|
## RENDER (quick)
|
|
9914
9926
|
|
|
9915
|
-
Report user + org
|
|
9916
|
-
|
|
9917
|
-
\`last_requested_lens_name\`, never the id). SILENT on quota
|
|
9927
|
+
Report user + org; show quota only when asked (quota / account status) or a
|
|
9928
|
+
window is exhausted. NEVER mention the lens unless asked (use
|
|
9929
|
+
\`last_requested_lens_name\`, never the id). SILENT on quota when
|
|
9918
9930
|
\`quota_error\` set, \`unlimited_credits\` true, or quota null. Else render
|
|
9919
9931
|
Daily/Weekly/Monthly from \`quota.user\` (fall back to \`quota.org\` if \`user\`
|
|
9920
9932
|
absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table when
|
|
@@ -9922,7 +9934,7 @@ absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table whe
|
|
|
9922
9934
|
|
|
9923
9935
|
---
|
|
9924
9936
|
|
|
9925
|
-
Show the user's account state \u2014 admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **
|
|
9937
|
+
Show the user's account state \u2014 admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **When you show quota, show it the way the web app does \u2014 a percentage-used + dollar-spend gauge per window, never raw "credits".** Each window in \`quota.<group>.spend[]\` carries \`current_units\` / \`max_units\` in dollar_cents (% used = the ratio, $ = \`/100\`); the \`quota.<group>.resources[]\` list gives the per-resource usage breakdown (\`count\`, plus \`max_units\` when a per-resource cap exists). **Pre-check the \`LENS_EXTRA_REFILL\` resource here before calling \`leadbay_extend_lens\`** \u2014 look in **\`quota.org.resources[]\`** first (admins), and fall back to **\`quota.user.resources[]\`** when \`quota.org\` is absent (non-admin callers only get the \`user\` group), matching the resource type **case-insensitively** (it may arrive as \`LENS_EXTRA_REFILL\` or \`lens_extra_refill\`). Its full requested batch must fit into the remaining daily quota or the call is rejected outright. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption.
|
|
9926
9938
|
|
|
9927
9939
|
**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.**
|
|
9928
9940
|
|
|
@@ -9956,7 +9968,7 @@ Some Leadbay tool responses include a \`_meta.notifications\` array listing **ba
|
|
|
9956
9968
|
|
|
9957
9969
|
- **Previous turn / before an MCP restart, and the user has NOT asked about it** \u2014 don't poll for it in the background. Simply continue the conversation; the next time you call any tool, the completed-work entry appears in \`_meta.notifications\` (also on \`leadbay_account_status.notifications\`). This is the ambient push path \u2014 leave it to do its job. **But if the user explicitly asks for status or to "wait for it to finish"** (e.g. a multi-turn flow where a job was launched in a prior turn and this turn says "wait for enrichment to finish, then \u2026"), DO poll its status tool now until done, exactly as for a this-turn job below \u2014 the ambient push only surfaces *completed* work, so it can't answer a live "is it done / wait for it" request while the job is still running.
|
|
9958
9970
|
- **This turn (you just launched it)** \u2014 the DEFAULT is: do NOT end your turn on the "launched" ack; stay active and poll the job's status tool in a loop until it reports done, then report the finished result yourself, rather than spinning forever or deferring the result to a later turn. (Two exceptions, detailed below: the user explicitly asked NOT to wait / to run it in the background; or it's a large qualification/import that's async by design \u2014 in those cases hand back the handle instead of looping.) Each status tool has its OWN terminal signal \u2014 poll until:
|
|
9959
|
-
- \`leadbay_bulk_enrich_status\` \u2192 \`all_done:true\` \u2014 OR \`overall_progress.done\` holds steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (
|
|
9971
|
+
- \`leadbay_bulk_enrich_status\` \u2192 \`all_done:true\` \u2014 OR \`overall_progress.done\` holds steady across several SPACED polls (~15\u201330s apart) over at least ~90s\u20132 min of elapsed time (a reservation the provider never answers stays \`done:false\`, so \`all_done\` can stay false forever). Don't call a plateau from the first few back-to-back reads \u2014 early on \`done\` sits flat while the backend spins up. Once the plateau is real, report what resolved and name what didn't.
|
|
9960
9972
|
- \`leadbay_qualify_status\` \u2192 \`still_running\` is empty: every launched lead has finished or failed. (\`in_progress\` also reads \`false\` on the fast path, but it can be \`null\` on the legacy/fallback read \u2014 so treat an empty \`still_running\` as terminal on its own; only require \`in_progress:false\` when that field is actually present.) LIKE imports, large qualification runs are async by design: \`leadbay_bulk_qualify_leads\` defaults to \`wait_for_completion:false\` for \`count > 5\` or chained workflows because blocking can time out, and \`leadbay_qualify_status\` may take minutes/hours. So don't force a long polling loop on a big run \u2014 return the handle/progress and let completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked to wait, or it's a small run that finishes quickly. A small \`wait_for_completion:true\` run you can poll to \`still_running\` empty inline.
|
|
9961
9973
|
- \`leadbay_import_status\` \u2192 \`status:"complete"\` (or \`"failed"\`). BUT imports are the exception to the stay-active loop: a large \`leadbay_import_leads({wait_for_completion:false})\` is meant to return a handle and resolve over minutes, and the tool does ONE refresh pass per call. Don't block the conversation looping on it \u2014 surface the returned progress/handle and let the completion arrive via \`_meta.notifications\` \u2014 UNLESS the user explicitly asked you to wait for the import, or it's a small import that finishes quickly.
|
|
9962
9974
|
|
|
@@ -9974,10 +9986,10 @@ Mirror the Leadbay web quota widget: three windows side by side \u2014 **Daily**
|
|
|
9974
9986
|
$ cap** figure, with a per-resource usage breakdown underneath. **Never speak in
|
|
9975
9987
|
raw "credits"** for quota \u2014 the unit is a percentage and a dollar spend.
|
|
9976
9988
|
|
|
9977
|
-
**
|
|
9978
|
-
|
|
9979
|
-
|
|
9980
|
-
|
|
9989
|
+
**Show the quota only when it matters** \u2014 when the user asks about their quota,
|
|
9990
|
+
usage or account status, or when a window is exhausted and blocks what they
|
|
9991
|
+
asked for. A plain "what account am I connected to?" is answered with user +
|
|
9992
|
+
org alone. Even then, the silence gate below comes first.
|
|
9981
9993
|
|
|
9982
9994
|
**Silence gate (check FIRST).** Render NOTHING about quota when any of these
|
|
9983
9995
|
holds \u2014 do not mention quota at all, do not say "unreadable", never tell the user
|
|
@@ -10037,7 +10049,7 @@ days"), computed against now \u2014 mirroring the widget's "r\xE9initialis\xE9 d
|
|
|
10037
10049
|
raw value is an ISO-8601 timestamp.
|
|
10038
10050
|
|
|
10039
10051
|
**Top-up (optional, subordinate).** When \`quota.topup\` is present, you MAY add one
|
|
10040
|
-
small line below the windows: \`Top-up: $<remaining_cents/100> of $<total_credit_cents/100> left\`.
|
|
10052
|
+
small line below the windows: \`Top-up: $<(remaining_cents / 100).toFixed(2)> of $<(total_credit_cents / 100).toFixed(2)> left\`.
|
|
10041
10053
|
Keep it secondary \u2014 the three window gauges are the headline. Omit when null.
|
|
10042
10054
|
|
|
10043
10055
|
**Legend** (once, below): \`\` \`\u25B0\` used \xB7 \`\u25B1\` remaining \`\`.
|
|
@@ -10045,7 +10057,7 @@ Keep it secondary \u2014 the three window gauges are the headline. Omit when nul
|
|
|
10045
10057
|
|
|
10046
10058
|
---
|
|
10047
10059
|
|
|
10048
|
-
WHEN TO USE:
|
|
10060
|
+
WHEN TO USE: when the user asks about their account, quota or version, after a 429 to explain to the user which resource window was exhausted and when it resets, and after the user signals a top-up so the agent can resume the interrupted workflow.
|
|
10049
10061
|
|
|
10050
10062
|
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.
|
|
10051
10063
|
`,
|
|
@@ -10071,8 +10083,8 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
10071
10083
|
|
|
10072
10084
|
3-col table of delivered leads in returned order: col 1 = 10-segment fit
|
|
10073
10085
|
bar + linked company \xB7 location \xB7 size; col 2 = why-fits \u226420 words; col 3
|
|
10074
|
-
= contact +
|
|
10075
|
-
(matched/examined/delivered/stop reason
|
|
10086
|
+
= contact + found channels. ALWAYS close with the honest funnel line
|
|
10087
|
+
(matched/examined/delivered/stop reason) \u2014 especially on 0
|
|
10076
10088
|
delivered. Full algorithm below.
|
|
10077
10089
|
|
|
10078
10090
|
---
|
|
@@ -10080,19 +10092,19 @@ delivered. Full algorithm below.
|
|
|
10080
10092
|
Submit a net-new lead search: the backend matches an ICP seed against the full
|
|
10081
10093
|
company universe, applies hard filters, skips what the org already knows
|
|
10082
10094
|
(\`novelty: org\`), optionally qualifies against the org's own intelligence
|
|
10083
|
-
(questions, tags, ideal buyer profile \u2014 frozen at submit), and optionally
|
|
10095
|
+
(questions, tags, ideal buyer profile \u2014 frozen at submit), and optionally reveals
|
|
10084
10096
|
contact channels. Polls up to \`wait_seconds\` (default 45); a longer job returns
|
|
10085
10097
|
\`still_running\` + \`next_poll\` \u2014 hand off to \`leadbay_lead_job_status\`. Jobs run
|
|
10086
10098
|
\u226430 min, results kept 30 days.
|
|
10087
10099
|
|
|
10088
|
-
**Free vs
|
|
10100
|
+
**Free vs usage quota \u2014 never use quota silently.** Default (\`qualify: false\`,
|
|
10089
10101
|
\`channels: []\`) is FREE: company profile + fit score + cached research +
|
|
10090
|
-
contact identity.
|
|
10091
|
-
|
|
10092
|
-
|
|
10102
|
+
contact identity. \`qualify: true\` (per candidate EXAMINED, capped by
|
|
10103
|
+
\`exploration_cap\`/\`max_cost\`) and \`channels\` (only when a value is found) draw
|
|
10104
|
+
on the org's usage quota; nothing is invoiced. Enforced in code: such a call is WITHHELD unless it
|
|
10093
10105
|
carries \`confirm: true\` \u2014 nothing is submitted and you get
|
|
10094
10106
|
\`mode: "needs_confirmation"\` with a real quote to show the user. Re-call with
|
|
10095
|
-
\`confirm: true\` on their go-ahead ("
|
|
10107
|
+
\`confirm: true\` on their go-ahead ("go ahead / get their emails" counts).
|
|
10096
10108
|
\`confirm: false\` vetoes. Free needs no consent. **Preview free first** \u2014
|
|
10097
10109
|
reshaping an off-profile seed is free, exploring it with \`qualify: true\` is
|
|
10098
10110
|
not.
|
|
@@ -10100,7 +10112,7 @@ not.
|
|
|
10100
10112
|
**Ad-hoc exclusions ("no chains") are enforced by NO tier** \u2014 \`filters\` has no
|
|
10101
10113
|
exclusion key, and \`qualify\` scores against the org's FROZEN questions and IBP,
|
|
10102
10114
|
which need not mention chains; the seed's inverse only shifts ranking.
|
|
10103
|
-
Violators can survive,
|
|
10115
|
+
Violators can survive, use quota and be delivered \u2014 post-filter them yourself
|
|
10104
10116
|
and say the tier didn't enforce it. Durable enforcement \u2192
|
|
10105
10117
|
\`leadbay_refine_prompt\`.
|
|
10106
10118
|
|
|
@@ -10147,9 +10159,9 @@ measured:
|
|
|
10147
10159
|
|
|
10148
10160
|
**Parameter notes**
|
|
10149
10161
|
- \`request_id\` (REQUIRED) is the retry contract: SAME value retrying the same
|
|
10150
|
-
ask (same live job, no double
|
|
10162
|
+
ask (same live job, no double launch); NEW for a changed ask. Derive from ask
|
|
10151
10163
|
+ archetype + date: \`gyms-dallas-2026-07-28\`.
|
|
10152
|
-
- Never lower \`min_ai_score\` together with \`channels\` \u2014 that
|
|
10164
|
+
- Never lower \`min_ai_score\` together with \`channels\` \u2014 that reveals emails for
|
|
10153
10165
|
leads the AI just scored as junk.
|
|
10154
10166
|
- \`count\` \u2264 50; \u22643 active jobs/org; \u226410 submits/hour (429 + Retry-After \u2014
|
|
10155
10167
|
wait, don't hammer).
|
|
@@ -10189,7 +10201,7 @@ when nothing was delivered.
|
|
|
10189
10201
|
**Column 3 \u2014 Contact**
|
|
10190
10202
|
|
|
10191
10203
|
- \`[Name](linkedin) \xB7 role\` (linked name mandatory when a LinkedIn URL
|
|
10192
|
-
exists; plain name otherwise). Below it, the
|
|
10204
|
+
exists; plain name otherwise). Below it, the FOUND channels only:
|
|
10193
10205
|
\`\u2709 value\` / \`\u260E value\` inline as plain text (they auto-linkify).
|
|
10194
10206
|
- Channel statuses: \`delivered\` \u2192 show value; \`already_owned\` \u2192 value +
|
|
10195
10207
|
*(already yours)*; \`masked\` \u2192 "on file \u2014 reveal via channels";
|
|
@@ -10200,16 +10212,17 @@ when nothing was delivered.
|
|
|
10200
10212
|
|
|
10201
10213
|
**The funnel line (mandatory, after the table):**
|
|
10202
10214
|
|
|
10203
|
-
One short line narrating the delivery honestly, from \`funnel\` +
|
|
10215
|
+
One short line narrating the delivery honestly, from \`funnel\` +
|
|
10204
10216
|
\`explain.scope_notes\`:
|
|
10205
10217
|
|
|
10206
10218
|
> Matched N \xB7 examined E \xB7 qualified Q \xB7 disqualified D \u2192 **delivered X of
|
|
10207
|
-
> the Y asked** \xB7 stopped: <stop_reason in plain words
|
|
10219
|
+
> the Y asked** \xB7 stopped: <stop_reason in plain words>.
|
|
10208
10220
|
|
|
10209
|
-
**
|
|
10210
|
-
|
|
10211
|
-
|
|
10212
|
-
|
|
10221
|
+
**No money, anywhere.** \`cost.*\`, \`estimated_cost.max\` and quotes are internal
|
|
10222
|
+
usage units. Never render them, never convert them to a currency, never call
|
|
10223
|
+
them a charge: the user's plan or top-up covers this work, and a price reads as
|
|
10224
|
+
a bill. If the user asks what a job used, show \`leadbay_account_status\`'s quota
|
|
10225
|
+
windows.
|
|
10213
10226
|
|
|
10214
10227
|
"of the Y asked" needs \`summary.items_requested\`, which submits carry but a
|
|
10215
10228
|
later \`leadbay_lead_job_status\` snapshot does not. Without it write **delivered
|
|
@@ -10217,7 +10230,7 @@ X** and stop \u2014 never back-fill Y from \`matched\`/\`examined\` (they count
|
|
|
10217
10230
|
candidates), never guess it.
|
|
10218
10231
|
|
|
10219
10232
|
Plain-word stop reasons: \`target_reached\` \u2192 omit (success), \`pool_exhausted\` \u2192
|
|
10220
|
-
"ran out of matching candidates", \`max_cost\` \u2192 "hit the
|
|
10233
|
+
"ran out of matching candidates", \`max_cost\` \u2192 "hit the job's usage cap", \`quota\` \u2192
|
|
10221
10234
|
"hit an org quota", \`time_budget\` \u2192 "hit the 30-min time budget".
|
|
10222
10235
|
|
|
10223
10236
|
**When \`delivered\` is 0**: NEVER say just "no results". Render no table; give
|
|
@@ -10242,7 +10255,7 @@ table \`Ref \u2192 Outcome\` translating \`status_reason\` to plain words:
|
|
|
10242
10255
|
\`low_confidence_identity\` \u2192 "couldn't safely match \u2014 check \`resolution.alternatives\`",
|
|
10243
10256
|
\`no_matching_contact\` \u2192 "no contact with the requested title",
|
|
10244
10257
|
\`disqualified\` \u2192 "evaluated: does not fit" (evidence is in the item when owned),
|
|
10245
|
-
\`enrichment_failed\` \u2192 "channel could not be sourced (
|
|
10258
|
+
\`enrichment_failed\` \u2192 "channel could not be sourced (no quota used)".
|
|
10246
10259
|
|
|
10247
10260
|
**\`items_truncated\`**: rows are a PREFIX, not the batch. Say so, and offer
|
|
10248
10261
|
\`leadbay_lead_job_status(job_id, since: next_since)\` for the rest.
|
|
@@ -10291,10 +10304,10 @@ Pick the 2-3 options that match what actually happened \u2014 never all seven:
|
|
|
10291
10304
|
| Observation | Suggest | Calls |
|
|
10292
10305
|
|---|---|---|
|
|
10293
10306
|
| Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 60) |
|
|
10294
|
-
| Free run delivered on-profile leads | "Qualify these N against your criteria (
|
|
10307
|
+
| Free run delivered on-profile leads | "Qualify these N against your criteria (uses quota \u2014 \`dry_run\` first)" | leadbay_qualify_leads(prior_deliveries: {job_id}) |
|
|
10295
10308
|
| Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
|
|
10296
10309
|
| Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
|
|
10297
|
-
| Stopped at
|
|
10310
|
+
| Stopped at the job's usage cap (\`stop_reason: max_cost\`) | "Raise the job's cap and get the remaining N" \u2014 no amount, no currency | 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) |
|
|
10298
10311
|
| 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 |
|
|
10299
10312
|
| User wants these tracked in Leadbay | "Add the keepers to a campaign" | leadbay_create_campaign / leadbay_add_leads_to_campaign |
|
|
10300
10313
|
`,
|
|
@@ -10857,7 +10870,7 @@ var getContacts = {
|
|
|
10857
10870
|
},
|
|
10858
10871
|
credits_used: {
|
|
10859
10872
|
type: "number",
|
|
10860
|
-
description: "Credits
|
|
10873
|
+
description: "Credits used by this reveal. Only meaningful when done:true. An explicit 0 alongside done:true means the provider returned nothing. Optional \u2014 when absent the cost is unknown and terminal-empty must NOT be inferred."
|
|
10861
10874
|
},
|
|
10862
10875
|
email_requested: { type: "boolean" },
|
|
10863
10876
|
phone_requested: { type: "boolean" }
|
|
@@ -15383,7 +15396,7 @@ var removePushback = {
|
|
|
15383
15396
|
var previewBulkEnrichment = {
|
|
15384
15397
|
name: "leadbay_preview_bulk_enrichment",
|
|
15385
15398
|
annotations: {
|
|
15386
|
-
title: "Preview bulk enrichment
|
|
15399
|
+
title: "Preview bulk enrichment",
|
|
15387
15400
|
readOnlyHint: true,
|
|
15388
15401
|
destructiveHint: false,
|
|
15389
15402
|
idempotentHint: true,
|
|
@@ -18468,6 +18481,9 @@ var researchLeadById = {
|
|
|
18468
18481
|
]
|
|
18469
18482
|
},
|
|
18470
18483
|
execute: async (client, params, _ctx) => {
|
|
18484
|
+
if (typeof params.leadId !== "string" || params.leadId.trim() === "") {
|
|
18485
|
+
throw client.makeError("INVALID_PARAMS", "leadId is required and must be a non-empty string", "Pass the lead's full 36-character UUID as `leadId`, exactly as leadbay_pull_leads items[].id returned it. If you only have the company name, call leadbay_research_lead_by_name_fuzzy instead.");
|
|
18486
|
+
}
|
|
18471
18487
|
const lensId = params.lensId ?? await client.resolveDefaultLens();
|
|
18472
18488
|
const leadId = params.leadId;
|
|
18473
18489
|
void client.request("POST", "/interactions", [
|
|
@@ -18647,6 +18663,133 @@ researchLeadById.execute = async (client, params, ctx) => {
|
|
|
18647
18663
|
return envelope;
|
|
18648
18664
|
};
|
|
18649
18665
|
|
|
18666
|
+
// ../../node_modules/.pnpm/fastest-levenshtein@1.0.16/node_modules/fastest-levenshtein/esm/mod.js
|
|
18667
|
+
var peq = new Uint32Array(65536);
|
|
18668
|
+
var myers_32 = (a, b) => {
|
|
18669
|
+
const n = a.length;
|
|
18670
|
+
const m = b.length;
|
|
18671
|
+
const lst = 1 << n - 1;
|
|
18672
|
+
let pv = -1;
|
|
18673
|
+
let mv = 0;
|
|
18674
|
+
let sc = n;
|
|
18675
|
+
let i = n;
|
|
18676
|
+
while (i--) {
|
|
18677
|
+
peq[a.charCodeAt(i)] |= 1 << i;
|
|
18678
|
+
}
|
|
18679
|
+
for (i = 0; i < m; i++) {
|
|
18680
|
+
let eq = peq[b.charCodeAt(i)];
|
|
18681
|
+
const xv = eq | mv;
|
|
18682
|
+
eq |= (eq & pv) + pv ^ pv;
|
|
18683
|
+
mv |= ~(eq | pv);
|
|
18684
|
+
pv &= eq;
|
|
18685
|
+
if (mv & lst) {
|
|
18686
|
+
sc++;
|
|
18687
|
+
}
|
|
18688
|
+
if (pv & lst) {
|
|
18689
|
+
sc--;
|
|
18690
|
+
}
|
|
18691
|
+
mv = mv << 1 | 1;
|
|
18692
|
+
pv = pv << 1 | ~(xv | mv);
|
|
18693
|
+
mv &= xv;
|
|
18694
|
+
}
|
|
18695
|
+
i = n;
|
|
18696
|
+
while (i--) {
|
|
18697
|
+
peq[a.charCodeAt(i)] = 0;
|
|
18698
|
+
}
|
|
18699
|
+
return sc;
|
|
18700
|
+
};
|
|
18701
|
+
var myers_x = (b, a) => {
|
|
18702
|
+
const n = a.length;
|
|
18703
|
+
const m = b.length;
|
|
18704
|
+
const mhc = [];
|
|
18705
|
+
const phc = [];
|
|
18706
|
+
const hsize = Math.ceil(n / 32);
|
|
18707
|
+
const vsize = Math.ceil(m / 32);
|
|
18708
|
+
for (let i = 0; i < hsize; i++) {
|
|
18709
|
+
phc[i] = -1;
|
|
18710
|
+
mhc[i] = 0;
|
|
18711
|
+
}
|
|
18712
|
+
let j = 0;
|
|
18713
|
+
for (; j < vsize - 1; j++) {
|
|
18714
|
+
let mv2 = 0;
|
|
18715
|
+
let pv2 = -1;
|
|
18716
|
+
const start2 = j * 32;
|
|
18717
|
+
const vlen2 = Math.min(32, m) + start2;
|
|
18718
|
+
for (let k = start2; k < vlen2; k++) {
|
|
18719
|
+
peq[b.charCodeAt(k)] |= 1 << k;
|
|
18720
|
+
}
|
|
18721
|
+
for (let i = 0; i < n; i++) {
|
|
18722
|
+
const eq = peq[a.charCodeAt(i)];
|
|
18723
|
+
const pb = phc[i / 32 | 0] >>> i & 1;
|
|
18724
|
+
const mb = mhc[i / 32 | 0] >>> i & 1;
|
|
18725
|
+
const xv = eq | mv2;
|
|
18726
|
+
const xh = ((eq | mb) & pv2) + pv2 ^ pv2 | eq | mb;
|
|
18727
|
+
let ph = mv2 | ~(xh | pv2);
|
|
18728
|
+
let mh = pv2 & xh;
|
|
18729
|
+
if (ph >>> 31 ^ pb) {
|
|
18730
|
+
phc[i / 32 | 0] ^= 1 << i;
|
|
18731
|
+
}
|
|
18732
|
+
if (mh >>> 31 ^ mb) {
|
|
18733
|
+
mhc[i / 32 | 0] ^= 1 << i;
|
|
18734
|
+
}
|
|
18735
|
+
ph = ph << 1 | pb;
|
|
18736
|
+
mh = mh << 1 | mb;
|
|
18737
|
+
pv2 = mh | ~(xv | ph);
|
|
18738
|
+
mv2 = ph & xv;
|
|
18739
|
+
}
|
|
18740
|
+
for (let k = start2; k < vlen2; k++) {
|
|
18741
|
+
peq[b.charCodeAt(k)] = 0;
|
|
18742
|
+
}
|
|
18743
|
+
}
|
|
18744
|
+
let mv = 0;
|
|
18745
|
+
let pv = -1;
|
|
18746
|
+
const start = j * 32;
|
|
18747
|
+
const vlen = Math.min(32, m - start) + start;
|
|
18748
|
+
for (let k = start; k < vlen; k++) {
|
|
18749
|
+
peq[b.charCodeAt(k)] |= 1 << k;
|
|
18750
|
+
}
|
|
18751
|
+
let score = m;
|
|
18752
|
+
for (let i = 0; i < n; i++) {
|
|
18753
|
+
const eq = peq[a.charCodeAt(i)];
|
|
18754
|
+
const pb = phc[i / 32 | 0] >>> i & 1;
|
|
18755
|
+
const mb = mhc[i / 32 | 0] >>> i & 1;
|
|
18756
|
+
const xv = eq | mv;
|
|
18757
|
+
const xh = ((eq | mb) & pv) + pv ^ pv | eq | mb;
|
|
18758
|
+
let ph = mv | ~(xh | pv);
|
|
18759
|
+
let mh = pv & xh;
|
|
18760
|
+
score += ph >>> m - 1 & 1;
|
|
18761
|
+
score -= mh >>> m - 1 & 1;
|
|
18762
|
+
if (ph >>> 31 ^ pb) {
|
|
18763
|
+
phc[i / 32 | 0] ^= 1 << i;
|
|
18764
|
+
}
|
|
18765
|
+
if (mh >>> 31 ^ mb) {
|
|
18766
|
+
mhc[i / 32 | 0] ^= 1 << i;
|
|
18767
|
+
}
|
|
18768
|
+
ph = ph << 1 | pb;
|
|
18769
|
+
mh = mh << 1 | mb;
|
|
18770
|
+
pv = mh | ~(xv | ph);
|
|
18771
|
+
mv = ph & xv;
|
|
18772
|
+
}
|
|
18773
|
+
for (let k = start; k < vlen; k++) {
|
|
18774
|
+
peq[b.charCodeAt(k)] = 0;
|
|
18775
|
+
}
|
|
18776
|
+
return score;
|
|
18777
|
+
};
|
|
18778
|
+
var distance = (a, b) => {
|
|
18779
|
+
if (a.length < b.length) {
|
|
18780
|
+
const tmp = b;
|
|
18781
|
+
b = a;
|
|
18782
|
+
a = tmp;
|
|
18783
|
+
}
|
|
18784
|
+
if (b.length === 0) {
|
|
18785
|
+
return a.length;
|
|
18786
|
+
}
|
|
18787
|
+
if (a.length <= 32) {
|
|
18788
|
+
return myers_32(a, b);
|
|
18789
|
+
}
|
|
18790
|
+
return myers_x(a, b);
|
|
18791
|
+
};
|
|
18792
|
+
|
|
18650
18793
|
// ../core/dist/composite/research-lead-by-name-fuzzy.js
|
|
18651
18794
|
var RESOLVE_TIMEOUT_MS = 1e4;
|
|
18652
18795
|
var MAX_AMBIGUOUS_CANDIDATES = 4;
|
|
@@ -18661,6 +18804,28 @@ function suggestionName(suggestion) {
|
|
|
18661
18804
|
function suggestionLeadId(suggestion) {
|
|
18662
18805
|
return suggestion.lead_id ?? suggestion.leadId;
|
|
18663
18806
|
}
|
|
18807
|
+
function nameWords(name) {
|
|
18808
|
+
return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
18809
|
+
}
|
|
18810
|
+
function isSameWord(a, b) {
|
|
18811
|
+
if (Math.max(a.length, b.length) > 3)
|
|
18812
|
+
return distance(a, b) <= 1;
|
|
18813
|
+
const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
|
|
18814
|
+
return shorter === longer || longer.length === shorter.length + 1 && longer.startsWith(shorter);
|
|
18815
|
+
}
|
|
18816
|
+
function coversWords(words, pool, lastMayBeCut) {
|
|
18817
|
+
return words.every((word, i) => pool.some((other) => isSameWord(word, other) || lastMayBeCut && i === words.length - 1 && other.startsWith(word)));
|
|
18818
|
+
}
|
|
18819
|
+
function isWordMatch(query, suggestion) {
|
|
18820
|
+
const matchType = suggestion.match_type ?? suggestion.matchType;
|
|
18821
|
+
if (matchType === "DOMAIN" || matchType === "PERSON")
|
|
18822
|
+
return true;
|
|
18823
|
+
const asked = nameWords(query);
|
|
18824
|
+
const found = nameWords(suggestionName(suggestion));
|
|
18825
|
+
if (asked.length === 0 || found.length === 0)
|
|
18826
|
+
return false;
|
|
18827
|
+
return coversWords(asked, found, true) || coversWords(found, asked, false) || asked.join("") === found.join("");
|
|
18828
|
+
}
|
|
18664
18829
|
function isLeadbayError(error) {
|
|
18665
18830
|
return typeof error === "object" && error !== null && error.error === true && typeof error.code === "string" && typeof error.message === "string" && typeof error.hint === "string";
|
|
18666
18831
|
}
|
|
@@ -18703,7 +18868,7 @@ async function resolveWithinLens(client, query, lensId) {
|
|
|
18703
18868
|
}
|
|
18704
18869
|
async function resolveAcrossVisibleCorpus(client, query) {
|
|
18705
18870
|
const suggestions = await client.request("GET", `/search/suggest?q=${encodeURIComponent(query)}`);
|
|
18706
|
-
return suggestions.map((suggestion) => {
|
|
18871
|
+
return suggestions.filter((suggestion) => isWordMatch(query, suggestion)).map((suggestion) => {
|
|
18707
18872
|
const id = suggestionLeadId(suggestion);
|
|
18708
18873
|
return {
|
|
18709
18874
|
id: id ?? "",
|
|
@@ -19082,9 +19247,9 @@ var GETTING_STARTED_MANIFEST = {
|
|
|
19082
19247
|
leadId: "<the highest-scoring lead id from step 2>"
|
|
19083
19248
|
},
|
|
19084
19249
|
forbidden_args: [
|
|
19085
|
-
"enrich \u2014 enrich:true launches a
|
|
19250
|
+
"enrich \u2014 enrich:true launches a contact reveal, which uses quota, off the back of a DRAFT click. They agreed to see an email written, not to reveal anyone. Gate 4 is where the reveal gets asked for, explicitly and on its own terms."
|
|
19086
19251
|
],
|
|
19087
|
-
spend: "This gate
|
|
19252
|
+
spend: "This gate uses NO quota. Call leadbay_prepare_outreach with leadId and nothing else. `recommended_contact` comes back in its post-enrichment shape with email and phone still null \u2014 that is EXPECTED, not a failure, and it is precisely the hook for gate 4: an email written, and nobody to send it to yet. Do not apologise for the missing contact, and do not reach for another tool to fill it in.",
|
|
19088
19253
|
branches: [
|
|
19089
19254
|
{
|
|
19090
19255
|
when: "always",
|
|
@@ -19100,7 +19265,7 @@ var GETTING_STARTED_MANIFEST = {
|
|
|
19100
19265
|
n: 4,
|
|
19101
19266
|
gate_label: "Find who to email",
|
|
19102
19267
|
gate_description: "Reveal the person at that company to send the draft to.",
|
|
19103
|
-
explain: "Point straight at the gap the draft just opened: they have an email ready and nobody to send it to \u2014 it's addressed to a job title, not a person. That's what this step fixes. Explain what enrichment IS: Leadbay can find which roles exist at that company, then reveal the actual human and how to reach them. WHY IT'S USEFUL: they ask for the operations director by name instead of pitching whoever answers the switchboard \u2014 the difference between a conversation and a dead end. Say plainly that the first look is free, and that revealing the contact
|
|
19268
|
+
explain: "Point straight at the gap the draft just opened: they have an email ready and nobody to send it to \u2014 it's addressed to a job title, not a person. That's what this step fixes. Explain what enrichment IS: Leadbay can find which roles exist at that company, then reveal the actual human and how to reach them. WHY IT'S USEFUL: they ask for the operations director by name instead of pitching whoever answers the switchboard \u2014 the difference between a conversation and a dead end. Say plainly that the first look is free, and that revealing the contact uses a little of their plan's quota and needs their say-so.",
|
|
19104
19269
|
next_steps: {
|
|
19105
19270
|
question: "Want to find out who to send that email to?",
|
|
19106
19271
|
options: [
|
|
@@ -19123,8 +19288,8 @@ var GETTING_STARTED_MANIFEST = {
|
|
|
19123
19288
|
leadIds: "[<the ONE lead you drafted for at step 3>] \u2014 an ARRAY, always",
|
|
19124
19289
|
lensId: "<the pinned lens id from step 2>"
|
|
19125
19290
|
},
|
|
19126
|
-
spend: "TWO BEATS \u2014 free preview FIRST, the real reveal only after the user confirms. Beat 1: call leadbay_enrich_titles with the drafted lead's id + lensId and NO titles / NO confirm / NO email / NO phone. That returns mode:'discover' \u2014 the FREE list of job titles at that company. Say plainly that nothing has
|
|
19127
|
-
quota_note: "After the reveal, close the loop on gate 1 in one line:
|
|
19291
|
+
spend: "TWO BEATS \u2014 free preview FIRST, the real reveal only after the user confirms. Beat 1: call leadbay_enrich_titles with the drafted lead's id + lensId and NO titles / NO confirm / NO email / NO phone. That returns mode:'discover' \u2014 the FREE list of job titles at that company. Say plainly that nothing has run yet. Beat 2: name the title the draft is addressed to, tell them BEFORE they decide that revealing it uses their plan's quota (here ONE contact; no amount, no price), and ask them to confirm. Only then call leadbay_enrich_titles AGAIN with leadIds: [<that lead id>] \u2014 ALWAYS the array, even for a single lead: `leadId` singular is not a key this tool reads, so it is dropped and the reveal falls back to the default wishlist selection, revealing the whole batch \u2014 plus the chosen title, confirm:true and email:true. Poll leadbay_bulk_enrich_status with the returned notification_id and lead_ids until all_done (or the count plateaus), and report the contact that actually resolved. NEVER launch the reveal without an explicit confirm: silence is not consent, and neither is 'they clicked the gate'. If they decline, keep the draft and the title and move on \u2014 that is a normal outcome, not a failure.",
|
|
19292
|
+
quota_note: "After the reveal, close the loop on gate 1 in one line: the reveal used a little of their plan's quota, never a price. Then say the thing that makes it land \u2014 the draft from gate 3 now has a real person and a real address to go to. Re-check leadbay_account_status if you want to show the moved windows. This is where gate 1's numbers stop being abstract: they just watched them move, and got something for it. Keep it to a line; no pricing pitch."
|
|
19128
19293
|
}
|
|
19129
19294
|
],
|
|
19130
19295
|
keep_going: KEEP_GOING,
|
|
@@ -19198,12 +19363,12 @@ var setQualificationQuestions = {
|
|
|
19198
19363
|
properties: {
|
|
19199
19364
|
questions: {
|
|
19200
19365
|
type: "array",
|
|
19201
|
-
items: { type: "string" },
|
|
19366
|
+
items: { type: "string", maxLength: 255 },
|
|
19202
19367
|
description: "Full replacement list of qualification questions (replaces ALL current questions). Mutually exclusive with add/remove."
|
|
19203
19368
|
},
|
|
19204
19369
|
add: {
|
|
19205
19370
|
type: "array",
|
|
19206
|
-
items: { type: "string" },
|
|
19371
|
+
items: { type: "string", maxLength: 255 },
|
|
19207
19372
|
description: "Questions to append to the current list (deduped). Mutually exclusive with `questions`."
|
|
19208
19373
|
},
|
|
19209
19374
|
remove: {
|
|
@@ -20058,7 +20223,7 @@ var accountStatus = {
|
|
|
20058
20223
|
},
|
|
20059
20224
|
quota: {
|
|
20060
20225
|
type: ["object", "null"],
|
|
20061
|
-
description: "Quota state, shaped like the web app. Prefer the `user` group (present for every caller; `org` is admin-only). Each group has `spend[]` and `resources[]` per daily/weekly/monthly window. `spend[]` is the PERCENTAGE-USED + DOLLAR gauge: `current_units`/`max_units` are dollar_cents, so % used = current/max and $ = /100 \u2014 render this like the frontend, never raw 'credits'. `spend[]` is empty for internal/free orgs with no OVERALL_SPEND quota; fall back to the `resources[]` breakdown then. `resources[]` gives per-resource usage: {resource_type, count (used), max_units (per-resource cap or null), window_type, resets_at}. `topup` (when present) is {remaining_cents, total_credit_cents}. Null if /quota_status failed (see quota_error) or genuinely returned nothing. NOTE: the `user`-group preference is for the DISPLAY gauge only \u2014 to pre-check LENS_EXTRA_REFILL before leadbay_extend_lens, look in `quota.org.resources[]` first (admins) and fall back to `quota.user.resources[]` when `org` is absent (non-admins), matching the resource type case-insensitively (LENS_EXTRA_REFILL / lens_extra_refill)."
|
|
20226
|
+
description: "Quota state, shaped like the web app. Prefer the `user` group (present for every caller; `org` is admin-only). Each group has `spend[]` and `resources[]` per daily/weekly/monthly window. `spend[]` is the PERCENTAGE-USED + DOLLAR gauge: `current_units`/`max_units` are dollar_cents, so % used = current/max and $ = /100 \u2014 render this like the frontend, never raw 'credits', and only when the user asks about quota or a window is exhausted. `spend[]` is empty for internal/free orgs with no OVERALL_SPEND quota; fall back to the `resources[]` breakdown then. `resources[]` gives per-resource usage: {resource_type, count (used), max_units (per-resource cap or null), window_type, resets_at}. `topup` (when present) is {remaining_cents, total_credit_cents}. Null if /quota_status failed (see quota_error) or genuinely returned nothing. NOTE: the `user`-group preference is for the DISPLAY gauge only \u2014 to pre-check LENS_EXTRA_REFILL before leadbay_extend_lens, look in `quota.org.resources[]` first (admins) and fall back to `quota.user.resources[]` when `org` is absent (non-admins), matching the resource type case-insensitively (LENS_EXTRA_REFILL / lens_extra_refill)."
|
|
20062
20227
|
},
|
|
20063
20228
|
quota_error: {
|
|
20064
20229
|
type: ["object", "null"],
|
|
@@ -22010,7 +22175,7 @@ async function launchOnSelection(client, args, ctx) {
|
|
|
22010
22175
|
preview,
|
|
22011
22176
|
lead_ids: leadIds,
|
|
22012
22177
|
titles,
|
|
22013
|
-
message: `An identical enrichment was started ${claim.seconds_since}s ago and has not returned its job id yet. Nothing was launched twice and no quota was
|
|
22178
|
+
message: `An identical enrichment was started ${claim.seconds_since}s ago and has not returned its job id yet. Nothing was launched twice and no quota was used.`,
|
|
22014
22179
|
next_action: "Wait a few seconds and call leadbay_enrich_titles again with the same arguments \u2014 it will hand back the job id once the first call settles. Do not treat this as a running job; there is no id to poll yet."
|
|
22015
22180
|
};
|
|
22016
22181
|
}
|
|
@@ -22028,8 +22193,8 @@ async function launchOnSelection(client, args, ctx) {
|
|
|
22028
22193
|
phone,
|
|
22029
22194
|
notification_id: already.notification_id,
|
|
22030
22195
|
launched_at: already.launched_at,
|
|
22031
|
-
message: `An identical enrichment was launched ${already.seconds_since}s ago; this call did NOT
|
|
22032
|
-
next_action: "Poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone, include_contacts: true}) until all_done, or until overall_progress.done holds steady across spaced polls (~15-30s apart) \u2014
|
|
22196
|
+
message: `An identical enrichment was launched ${already.seconds_since}s ago; this call did NOT use quota again. Poll the original job rather than relaunching.`,
|
|
22197
|
+
next_action: "Poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone, include_contacts: true}) until all_done, or until overall_progress.done holds steady across spaced polls (~15-30s apart) \u2014 a reservation the provider never answers never flips. Pass titles/email/phone every time: they scope counting to the roles and channel THIS run asked for, so a contact enriched earlier cannot report the run as finished. Then report what landed and name what didn't."
|
|
22033
22198
|
};
|
|
22034
22199
|
}
|
|
22035
22200
|
ctx?.progress?.({
|
|
@@ -22066,8 +22231,8 @@ async function launchOnSelection(client, args, ctx) {
|
|
|
22066
22231
|
lead_ids: leadIds,
|
|
22067
22232
|
notification_id: notificationId,
|
|
22068
22233
|
launched_at: remembered.launched_at,
|
|
22069
|
-
message: notificationId ? "Enrichment job launched (runs async). Unless the user asked NOT to wait, do NOT end your turn here \u2014 poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone}) until all_done OR until progress plateaus (overall_progress.done stops climbing across spaced polls \u2014
|
|
22070
|
-
next_action: notificationId ? "Unless the user explicitly asked NOT to wait, poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone, include_contacts: true}) until all_done \u2014 OR until overall_progress.done holds steady across several SPACED polls (~15-30s apart, ~90s-2min elapsed; don't call a plateau from the first back-to-back reads, and not while partial_failures is present \u2014 that's transient, respect retry_after). Carry titles/email/phone on every poll \u2014 they scope counting to the roles and channel THIS run asked for, and without them a contact enriched months ago counts as done and all_done flips true before anything landed. Then report the resolved enrichment in THIS turn, naming what landed and what didn't. If the user DID ask not to wait, hand back the notification_id \u2014 it resolves later from any conversation." : "Poll leadbay_bulk_enrich_status({lead_ids, titles, email, phone, include_contacts: true}) every ~30s. It scopes counting to these titles and
|
|
22234
|
+
message: notificationId ? "Enrichment job launched (runs async). Unless the user asked NOT to wait, do NOT end your turn here \u2014 poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone}) until all_done OR until progress plateaus (overall_progress.done stops climbing across spaced polls \u2014 a reservation the provider never answers keeps all_done:false forever), then report the finished contacts yourself. The notification_id keeps working across conversations and days; completion also surfaces via _meta.notifications / leadbay_account_status.notifications." : "Enrichment job launched, but the backend returned no notification_id, so there is no job id to poll. Poll leadbay_bulk_enrich_status({lead_ids, titles, email, phone}) instead \u2014 it answers per lead without a job id.",
|
|
22235
|
+
next_action: notificationId ? "Unless the user explicitly asked NOT to wait, poll leadbay_bulk_enrich_status({notification_id, lead_ids, titles, email, phone, include_contacts: true}) until all_done \u2014 OR until overall_progress.done holds steady across several SPACED polls (~15-30s apart, ~90s-2min elapsed; don't call a plateau from the first back-to-back reads, and not while partial_failures is present \u2014 that's transient, respect retry_after). Carry titles/email/phone on every poll \u2014 they scope counting to the roles and channel THIS run asked for, and without them a contact enriched months ago counts as done and all_done flips true before anything landed. Then report the resolved enrichment in THIS turn, naming what landed and what didn't. If the user DID ask not to wait, hand back the notification_id \u2014 it resolves later from any conversation." : "Poll leadbay_bulk_enrich_status({lead_ids, titles, email, phone, include_contacts: true}) every ~30s. It scopes counting to these titles and to the channels this run asked for, and counts a reservation once it has settled, so you do not have to do that yourself. Stop once overall_progress.done stops growing across a couple of spaced re-checks (~90s-2min) \u2014 a reservation the provider never answers never flips."
|
|
22071
22236
|
};
|
|
22072
22237
|
}
|
|
22073
22238
|
async function launchEnrichment(client, args, ctx) {
|
|
@@ -22132,14 +22297,14 @@ var enrichTitles = {
|
|
|
22132
22297
|
},
|
|
22133
22298
|
confirm: {
|
|
22134
22299
|
type: "boolean",
|
|
22135
|
-
description: "Explicit
|
|
22300
|
+
description: "Explicit go-ahead for the email/phone reveal (uses quota). true = go ahead and launch. false = do NOT launch (a veto: returns mode:'needs_confirmation' and launches nothing, even on hosts without elicitation, and even if an email/phone channel was set). Omitted (and no explicit email/phone channel) \u2192 an elicitation-capable host asks the user before launching; a decline returns mode:'needs_confirmation'. Passing email:true/phone:true also counts as consent."
|
|
22136
22301
|
}
|
|
22137
22302
|
},
|
|
22138
22303
|
additionalProperties: false
|
|
22139
22304
|
},
|
|
22140
22305
|
outputSchema: {
|
|
22141
22306
|
type: "object",
|
|
22142
|
-
description: "Branchy return shape; the `mode` (or `status`) field tells the agent which branch it got. Modes: 'discover' (no titles passed), 'preview_only' (no enrichable contacts), 'dry_run', 'needs_confirmation' (
|
|
22307
|
+
description: "Branchy return shape; the `mode` (or `status`) field tells the agent which branch it got. Modes: 'discover' (no titles passed), 'preview_only' (no enrichable contacts), 'dry_run', 'needs_confirmation' (launch withheld pending user consent), 'already_launched' (idempotent reuse), 'launch_in_flight' (an identical launch is mid-flight and has no id yet), 'launched' (happy path). Status: 'quota_exceeded' (429).",
|
|
22143
22308
|
properties: {
|
|
22144
22309
|
mode: {
|
|
22145
22310
|
type: "string",
|
|
@@ -22175,7 +22340,7 @@ var enrichTitles = {
|
|
|
22175
22340
|
},
|
|
22176
22341
|
credits_remaining: {
|
|
22177
22342
|
type: ["number", "string", "null"],
|
|
22178
|
-
description: `Advisory-only balance (billing.ai_credits), present in discover / preview_only / dry_run modes. Enrichment is gated by QUOTA (email + phone reveals consume the per-window allowance in leadbay_account_status), NOT by this number \u2014 do NOT present it as a
|
|
22343
|
+
description: `Advisory-only balance (billing.ai_credits), present in discover / preview_only / dry_run modes. Enrichment is gated by QUOTA (email + phone reveals consume the per-window allowance in leadbay_account_status), NOT by this number \u2014 do NOT present it as a gate, do NOT say 'you have N credits', and NEVER refuse enrichment because it's low or zero. Confirm the run by naming enrichable_contacts + the channels, not a credit figure. Null = billing unavailable. The string "unlimited" = an internal/unlimited account: proceed freely and say nothing about credits or quota.`
|
|
22179
22344
|
},
|
|
22180
22345
|
selected_lead_count: {
|
|
22181
22346
|
type: "number",
|
|
@@ -22369,8 +22534,8 @@ var enrichTitles = {
|
|
|
22369
22534
|
enrichable_contacts: preview2.enrichable_contacts,
|
|
22370
22535
|
credits_remaining: await readCreditsRemaining(client),
|
|
22371
22536
|
available_titles: availableTitles2,
|
|
22372
|
-
message: "Enrichment not launched \u2014 confirm:false was passed (
|
|
22373
|
-
next_action: "Re-call leadbay_enrich_titles with confirm:true once the user approves the
|
|
22537
|
+
message: "Enrichment not launched \u2014 confirm:false was passed (declined). Title & LinkedIn are already on the contact (free); enrichment is the email/phone reveal, which uses quota. Re-call with confirm:true (or email:true) to launch.",
|
|
22538
|
+
next_action: "Re-call leadbay_enrich_titles with confirm:true once the user approves the reveal."
|
|
22374
22539
|
}
|
|
22375
22540
|
};
|
|
22376
22541
|
} else if (!willElicit) {
|
|
@@ -22449,8 +22614,8 @@ var enrichTitles = {
|
|
|
22449
22614
|
enrichable_contacts: preview.enrichable_contacts,
|
|
22450
22615
|
credits_remaining: creditsRemaining,
|
|
22451
22616
|
available_titles: availableTitles,
|
|
22452
|
-
message: "Enrichment not launched \u2014 awaiting confirmation. Title & LinkedIn are already on the contact (free); enrichment is the
|
|
22453
|
-
next_action: "Confirm the
|
|
22617
|
+
message: "Enrichment not launched \u2014 awaiting confirmation. Title & LinkedIn are already on the contact (free); enrichment is the email/phone reveal, which uses quota. Re-call with confirm:true (or email:true) to launch.",
|
|
22618
|
+
next_action: "Confirm the reveal with the user, then call leadbay_enrich_titles again with confirm:true."
|
|
22454
22619
|
};
|
|
22455
22620
|
}
|
|
22456
22621
|
return await launchEnrichment(client, {
|
|
@@ -22510,11 +22675,11 @@ var bulkEnrichStatus = {
|
|
|
22510
22675
|
},
|
|
22511
22676
|
email: {
|
|
22512
22677
|
type: "boolean",
|
|
22513
|
-
description: "The `email` flag the launch returned.
|
|
22678
|
+
description: "The `email` flag the launch returned. Scopes counting to reservations that asked for email (`enrichment.email_requested`), so a contact reserved by an earlier phone-only run is not counted in this one."
|
|
22514
22679
|
},
|
|
22515
22680
|
phone: {
|
|
22516
22681
|
type: "boolean",
|
|
22517
|
-
description: "The `phone` flag the launch returned. Same rule as `email`."
|
|
22682
|
+
description: "The `phone` flag the launch returned. Same rule as `email`, on `enrichment.phone_requested`."
|
|
22518
22683
|
},
|
|
22519
22684
|
include_contacts: {
|
|
22520
22685
|
type: "boolean",
|
|
@@ -22548,7 +22713,7 @@ var bulkEnrichStatus = {
|
|
|
22548
22713
|
},
|
|
22549
22714
|
credits_remaining: {
|
|
22550
22715
|
type: ["number", "string", "null"],
|
|
22551
|
-
description: `Advisory internal context only \u2014 do NOT display it. It's billing.ai_credits (a CONSUMED counter, not a remaining balance), so it can read 0 on a fresh/quota-backed account and printing _(N credits remaining)_ would falsely say 'out of credits'. Enrichment is gated by QUOTA, not this number
|
|
22716
|
+
description: `Advisory internal context only \u2014 do NOT display it. It's billing.ai_credits (a CONSUMED counter, not a remaining balance), so it can read 0 on a fresh/quota-backed account and printing _(N credits remaining)_ would falsely say 'out of credits'. Enrichment is gated by QUOTA, not this number \u2014 never a credits line here. ("unlimited" = internal/unlimited account; still nothing to display.) A per-run 'credits used' figure is intentionally NOT returned \u2014 getContacts can't scope cost to this bulk.`
|
|
22552
22717
|
},
|
|
22553
22718
|
partial_failures: {
|
|
22554
22719
|
type: "array",
|
|
@@ -22599,22 +22764,21 @@ var bulkEnrichStatus = {
|
|
|
22599
22764
|
}
|
|
22600
22765
|
if (leadIds.length > 0) {
|
|
22601
22766
|
const wantTitles = new Set((params.titles ?? []).map((t) => t.trim().toLowerCase()));
|
|
22602
|
-
const
|
|
22603
|
-
if (c
|
|
22604
|
-
return false;
|
|
22605
|
-
if (params.email && !c.email)
|
|
22767
|
+
const inThisRun = (c) => {
|
|
22768
|
+
if (params.email && c.enrichment.email_requested === false)
|
|
22606
22769
|
return false;
|
|
22607
|
-
if (params.phone &&
|
|
22770
|
+
if (params.phone && c.enrichment.phone_requested === false)
|
|
22608
22771
|
return false;
|
|
22609
22772
|
return true;
|
|
22610
22773
|
};
|
|
22774
|
+
const settled = (c) => c.enrichment.done === true;
|
|
22611
22775
|
let doneSoFar = 0;
|
|
22612
22776
|
const totalLeads = leadIds.length;
|
|
22613
22777
|
const results = await pMap(leadIds, async (leadId) => {
|
|
22614
22778
|
try {
|
|
22615
22779
|
const out = await getContacts.execute(client, { leadId });
|
|
22616
22780
|
const contacts = Array.isArray(out?.contacts) ? out.contacts : [];
|
|
22617
|
-
const enrichable = contacts.filter((c) => c && c.enrichment && (wantTitles.size === 0 || typeof c.job_title === "string" && wantTitles.has(c.job_title.trim().toLowerCase())));
|
|
22781
|
+
const enrichable = contacts.filter((c) => c && c.enrichment && inThisRun(c) && (wantTitles.size === 0 || typeof c.job_title === "string" && wantTitles.has(c.job_title.trim().toLowerCase())));
|
|
22618
22782
|
const fe = Array.isArray(out?._fetch_errors) ? out._fetch_errors : [];
|
|
22619
22783
|
doneSoFar += 1;
|
|
22620
22784
|
ctx?.progress?.({
|
|
@@ -22633,7 +22797,7 @@ var bulkEnrichStatus = {
|
|
|
22633
22797
|
return {
|
|
22634
22798
|
kind: "ok",
|
|
22635
22799
|
lead_id: leadId,
|
|
22636
|
-
done: enrichable.filter(
|
|
22800
|
+
done: enrichable.filter(settled).length,
|
|
22637
22801
|
total: enrichable.length,
|
|
22638
22802
|
...includeContacts ? { contacts } : {}
|
|
22639
22803
|
};
|
|
@@ -23619,7 +23783,7 @@ async function waitForJob(client, jobId, waitSeconds, ctx, itemsRequested, since
|
|
|
23619
23783
|
ctx?.progress?.({
|
|
23620
23784
|
progress: f.delivered ?? 0,
|
|
23621
23785
|
total: itemsRequested,
|
|
23622
|
-
message: `${snap.job.state}: ${f.examined ?? 0} examined, ${f.delivered ?? 0} delivered
|
|
23786
|
+
message: `${snap.job.state}: ${f.examined ?? 0} examined, ${f.delivered ?? 0} delivered`
|
|
23623
23787
|
});
|
|
23624
23788
|
}
|
|
23625
23789
|
return snap;
|
|
@@ -23762,7 +23926,7 @@ function mockedSubmitPreview(submit, tool, region) {
|
|
|
23762
23926
|
error: true,
|
|
23763
23927
|
code: "MALFORMED_SUBMIT_RESPONSE",
|
|
23764
23928
|
message: `${tool}: the submit succeeded but the response carried no job_id, so the job cannot be polled.`,
|
|
23765
|
-
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
|
|
23929
|
+
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 launching twice."
|
|
23766
23930
|
};
|
|
23767
23931
|
}
|
|
23768
23932
|
return {
|
|
@@ -23774,15 +23938,94 @@ function mockedSubmitPreview(submit, tool, region) {
|
|
|
23774
23938
|
region
|
|
23775
23939
|
};
|
|
23776
23940
|
}
|
|
23777
|
-
|
|
23778
|
-
|
|
23779
|
-
|
|
23780
|
-
|
|
23781
|
-
|
|
23782
|
-
|
|
23783
|
-
|
|
23784
|
-
|
|
23941
|
+
var LEAD_REASON_CHARS = 180;
|
|
23942
|
+
var SIGNAL_CHARS = 140;
|
|
23943
|
+
var MAX_SIGNALS = 2;
|
|
23944
|
+
var MAX_ALTERNATIVE_CONTACTS = 1;
|
|
23945
|
+
var RESULT_CHARS = 52e3;
|
|
23946
|
+
var LEAN_ROW_CHARS = 700;
|
|
23947
|
+
function clip(s, n) {
|
|
23948
|
+
if (typeof s !== "string" || s.length === 0)
|
|
23949
|
+
return void 0;
|
|
23950
|
+
return s.length <= n ? s : s.slice(0, n - 1).trimEnd() + "\u2026";
|
|
23951
|
+
}
|
|
23952
|
+
function compactContact(c) {
|
|
23953
|
+
if (!c || typeof c !== "object")
|
|
23954
|
+
return c ?? null;
|
|
23955
|
+
const channels = {};
|
|
23956
|
+
for (const [name, state] of Object.entries(c.channels ?? {})) {
|
|
23957
|
+
const s = state;
|
|
23958
|
+
channels[name] = s?.value != null ? { status: s.status, value: s.value } : { status: s?.status };
|
|
23785
23959
|
}
|
|
23960
|
+
return {
|
|
23961
|
+
lead_contact_id: c.lead_contact_id,
|
|
23962
|
+
name: c.name,
|
|
23963
|
+
role: clip(c.role, 80),
|
|
23964
|
+
linkedin: c.linkedin,
|
|
23965
|
+
channels
|
|
23966
|
+
};
|
|
23967
|
+
}
|
|
23968
|
+
function compactLead(lead, lean = false) {
|
|
23969
|
+
const company = lead.company ?? {};
|
|
23970
|
+
const fit = lead.fit ?? {};
|
|
23971
|
+
const q = fit.components?.qualification ?? {};
|
|
23972
|
+
const answers = Array.isArray(q.questions) ? q.questions : [];
|
|
23973
|
+
const best = answers.filter((a) => a?.verdict === "yes").sort((a, b) => (b?.score ?? 0) - (a?.score ?? 0))[0];
|
|
23974
|
+
const signals = (Array.isArray(lead.signals) ? lead.signals : []).filter((s) => s?.summary).sort((a, b) => Number(!!b.hot) - Number(!!a.hot) || String(b.date ?? "").localeCompare(String(a.date ?? ""))).slice(0, MAX_SIGNALS).map((s) => ({ summary: clip(s.summary, SIGNAL_CHARS), date: s.date, source_url: s.source_url }));
|
|
23975
|
+
const location = company.location ? { city: company.location.city, region: company.location.region, country: company.location.country } : void 0;
|
|
23976
|
+
return {
|
|
23977
|
+
lead_id: lead.lead_id,
|
|
23978
|
+
company: {
|
|
23979
|
+
name: company.name,
|
|
23980
|
+
website: company.website,
|
|
23981
|
+
location,
|
|
23982
|
+
employees: company.employees,
|
|
23983
|
+
// Kept on trimmed rows too: it is the rendering rules' last fallback for
|
|
23984
|
+
// "why it fits" once the reason and the tags are gone.
|
|
23985
|
+
description: clip(company.description ?? company.short_description, lean ? 80 : 140)
|
|
23986
|
+
},
|
|
23987
|
+
fit: {
|
|
23988
|
+
available: fit.available,
|
|
23989
|
+
score: fit.score,
|
|
23990
|
+
reasoning: lean ? void 0 : clip(best?.reasoning ?? q.ibp?.reasoning, LEAD_REASON_CHARS),
|
|
23991
|
+
components: {
|
|
23992
|
+
qualification: {
|
|
23993
|
+
available: q.available,
|
|
23994
|
+
ai_score: q.ai_score,
|
|
23995
|
+
matched_tags: lean ? void 0 : (Array.isArray(q.matched_tags) ? q.matched_tags : []).map((t) => typeof t === "string" ? t : t?.tag).filter(Boolean)
|
|
23996
|
+
}
|
|
23997
|
+
}
|
|
23998
|
+
},
|
|
23999
|
+
signals: lean ? void 0 : signals,
|
|
24000
|
+
contact: compactContact(lead.contact),
|
|
24001
|
+
alternative_contacts: lean ? void 0 : (Array.isArray(lead.alternative_contacts) ? lead.alternative_contacts : []).slice(0, MAX_ALTERNATIVE_CONTACTS).map(compactContact),
|
|
24002
|
+
// Unbounded, so only full rows carry it: they are measured, trimmed rows
|
|
24003
|
+
// are only reserved for.
|
|
24004
|
+
custom_fields: !lean && Array.isArray(lead.custom_fields) && lead.custom_fields.length > 0 ? lead.custom_fields : void 0,
|
|
24005
|
+
evidence_trimmed: lean ? true : void 0
|
|
24006
|
+
};
|
|
24007
|
+
}
|
|
24008
|
+
function splitItems(snapshot) {
|
|
24009
|
+
const leanRow = (item) => ({
|
|
24010
|
+
ref: item.ref,
|
|
24011
|
+
status: item.status,
|
|
24012
|
+
status_reason: item.status_reason,
|
|
24013
|
+
...item.resolution ? { resolution: item.resolution } : {},
|
|
24014
|
+
seq: item.seq,
|
|
24015
|
+
...item.lead ? { lead: compactLead(item.lead, true) } : {}
|
|
24016
|
+
});
|
|
24017
|
+
const skipped = snapshot.items.filter((i) => i.status === "skipped").map(leanRow);
|
|
24018
|
+
const rows = snapshot.items.filter((i) => i.status !== "skipped");
|
|
24019
|
+
let room = RESULT_CHARS - JSON.stringify(skipped).length;
|
|
24020
|
+
const leads = rows.map((item, i) => {
|
|
24021
|
+
const { from_cache: _fromCache, ...rest } = item;
|
|
24022
|
+
const full = item.lead ? { ...rest, lead: compactLead(item.lead) } : rest;
|
|
24023
|
+
const fullChars = JSON.stringify(full).length;
|
|
24024
|
+
const reserve = (rows.length - i - 1) * LEAN_ROW_CHARS;
|
|
24025
|
+
const out = fullChars + reserve <= room ? full : leanRow(item);
|
|
24026
|
+
room -= out === full ? fullChars : JSON.stringify(out).length;
|
|
24027
|
+
return out;
|
|
24028
|
+
});
|
|
23786
24029
|
return { leads, skipped };
|
|
23787
24030
|
}
|
|
23788
24031
|
function compactBody(body) {
|
|
@@ -23870,7 +24113,7 @@ function rejectMalformedExclusions(ids) {
|
|
|
23870
24113
|
error: true,
|
|
23871
24114
|
code: "INVALID_EXCLUDE_LEAD_ID",
|
|
23872
24115
|
message: `exclude_lead_ids has ${bad.length} entr${bad.length === 1 ? "y" : "ies"} that is not a lead id: ${bad.join(", ")}.`,
|
|
23873
|
-
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
|
|
24116
|
+
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 that exact lead and use quota on it again."
|
|
23874
24117
|
};
|
|
23875
24118
|
}
|
|
23876
24119
|
function rejectOversizedExclusions(ids) {
|
|
@@ -23926,7 +24169,7 @@ function readSpendFlag(value, field) {
|
|
|
23926
24169
|
error: true,
|
|
23927
24170
|
code: "BAD_INPUT",
|
|
23928
24171
|
message: `${field} must be a boolean (got ${Array.isArray(value) ? "array" : typeof value}: ${JSON.stringify(value)}).`,
|
|
23929
|
-
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
|
|
24172
|
+
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 run uses the org's quota, so an unrecognised value is refused rather than guessed.`
|
|
23930
24173
|
};
|
|
23931
24174
|
}
|
|
23932
24175
|
function rejectCountryLocations(locations, region) {
|
|
@@ -24044,15 +24287,18 @@ var findNewLeads = {
|
|
|
24044
24287
|
},
|
|
24045
24288
|
qualify: {
|
|
24046
24289
|
type: "boolean",
|
|
24047
|
-
description: "Run fresh AI qualification and drop candidates scoring below min_ai_score.
|
|
24290
|
+
description: "Run fresh AI qualification and drop candidates scoring below min_ai_score. Uses the org's usage quota for every candidate EXAMINED (survivor or not). Default false (free)."
|
|
24048
24291
|
},
|
|
24049
24292
|
min_ai_score: {
|
|
24050
24293
|
type: "number",
|
|
24294
|
+
minimum: -30,
|
|
24295
|
+
maximum: 30,
|
|
24051
24296
|
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."
|
|
24052
24297
|
},
|
|
24053
24298
|
contact_titles: {
|
|
24054
24299
|
type: "array",
|
|
24055
24300
|
items: { type: "string" },
|
|
24301
|
+
maxItems: 10,
|
|
24056
24302
|
description: "Wanted decision-maker titles (max 10), matched semantically cross-language."
|
|
24057
24303
|
},
|
|
24058
24304
|
title_gate: {
|
|
@@ -24063,7 +24309,7 @@ var findNewLeads = {
|
|
|
24063
24309
|
channels: {
|
|
24064
24310
|
type: "array",
|
|
24065
24311
|
items: { type: "string", enum: ["email", "phone"] },
|
|
24066
|
-
description: "Contact channels to
|
|
24312
|
+
description: "Contact channels to find (email, phone). Uses the org's usage quota only when a value is found. Empty = free identity tier."
|
|
24067
24313
|
},
|
|
24068
24314
|
exclude_lead_ids: {
|
|
24069
24315
|
type: "array",
|
|
@@ -24077,7 +24323,7 @@ var findNewLeads = {
|
|
|
24077
24323
|
},
|
|
24078
24324
|
max_cost: {
|
|
24079
24325
|
type: "number",
|
|
24080
|
-
description: "
|
|
24326
|
+
description: "Usage cap for the whole job, in internal units. Leave it unset: the default (100000) covers any job. What the requested channels need is kept for them; a cap below that is refused, naming the minimum. Never show it to the user as money."
|
|
24081
24327
|
},
|
|
24082
24328
|
exploration_cap: {
|
|
24083
24329
|
type: "number",
|
|
@@ -24085,20 +24331,20 @@ var findNewLeads = {
|
|
|
24085
24331
|
},
|
|
24086
24332
|
request_id: {
|
|
24087
24333
|
type: "string",
|
|
24088
|
-
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
|
|
24334
|
+
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 launching twice. Use a NEW value only for a genuinely new ask."
|
|
24089
24335
|
},
|
|
24090
24336
|
lang: { type: "string", description: "Output language (default: user's language)." },
|
|
24091
24337
|
confirm: {
|
|
24092
24338
|
type: "boolean",
|
|
24093
|
-
description: "Explicit
|
|
24339
|
+
description: "Explicit go-ahead, required only for a search that uses quota (qualify:true and/or channels). true = the user approved the quote, go ahead. false = a veto (returns mode:'needs_confirmation', uses nothing). Omitted on such a 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."
|
|
24094
24340
|
},
|
|
24095
24341
|
dry_run: {
|
|
24096
24342
|
type: "boolean",
|
|
24097
|
-
description: "Validate + worst-case
|
|
24343
|
+
description: "Validate + worst-case usage estimate + quota forecast. No job, uses nothing. Use before the first quota-using run of a session."
|
|
24098
24344
|
},
|
|
24099
24345
|
wait_seconds: {
|
|
24100
24346
|
type: "number",
|
|
24101
|
-
description: "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Free searches usually finish inside the window;
|
|
24347
|
+
description: "How long to poll before returning (default 45, max 180, 0 = submit + one snapshot). Free searches usually finish inside the window; qualified exploration can take minutes \u2014 the result then carries still_running:true and the job_id to check with leadbay_lead_job_status."
|
|
24102
24348
|
}
|
|
24103
24349
|
},
|
|
24104
24350
|
required: ["count", "request_id"],
|
|
@@ -24200,13 +24446,13 @@ var findNewLeads = {
|
|
|
24200
24446
|
submitted: false,
|
|
24201
24447
|
vetoed,
|
|
24202
24448
|
paid_because: [
|
|
24203
|
-
buysQualification ? "qualify: true (
|
|
24449
|
+
buysQualification ? "qualify: true (uses quota per candidate examined)" : null,
|
|
24204
24450
|
buysChannels ? `channels requested: ${params.channels.join(", ")}` : null
|
|
24205
24451
|
].filter(Boolean),
|
|
24206
24452
|
quote: forecast,
|
|
24207
24453
|
estimated_cost: forecast?.estimated_cost ?? null,
|
|
24208
24454
|
items_requested: forecast?.items_requested ?? null,
|
|
24209
|
-
hint: vetoed ? "confirm:false vetoed the
|
|
24455
|
+
hint: vetoed ? "confirm:false vetoed the run \u2014 nothing was submitted. Re-call with confirm:true to proceed, or drop qualify/channels for a free search." : "Tell the user what will run and that it uses their plan's quota (no amounts, no money), get an explicit go-ahead, then re-call with confirm:true. For a free search instead: omit qualify and channels.",
|
|
24210
24456
|
region: client.region
|
|
24211
24457
|
};
|
|
24212
24458
|
}
|
|
@@ -24413,7 +24659,7 @@ var qualifyLeads = {
|
|
|
24413
24659
|
},
|
|
24414
24660
|
prior_deliveries: {
|
|
24415
24661
|
type: "object",
|
|
24416
|
-
description: "Selector expanding the org's past MCP deliveries into refs \u2014
|
|
24662
|
+
description: "Selector expanding the org's past MCP deliveries into refs \u2014 delivered leads stay re-readable after result expiry. Combine with lead_refs or use alone.",
|
|
24417
24663
|
properties: {
|
|
24418
24664
|
job_id: { type: "string" },
|
|
24419
24665
|
since: { type: "string", description: "ISO instant lower bound." },
|
|
@@ -24423,7 +24669,7 @@ var qualifyLeads = {
|
|
|
24423
24669
|
},
|
|
24424
24670
|
qualify: {
|
|
24425
24671
|
type: "boolean",
|
|
24426
|
-
description: "Fresh AI qualification (default true;
|
|
24672
|
+
description: "Fresh AI qualification (default true; uses the org's usage quota for each lead needing fresh research+scoring, none when a fresh dossier exists). Owned disqualified leads come back WITH their negative evidence."
|
|
24427
24673
|
},
|
|
24428
24674
|
contact_titles: {
|
|
24429
24675
|
type: "array",
|
|
@@ -24438,24 +24684,24 @@ var qualifyLeads = {
|
|
|
24438
24684
|
channels: {
|
|
24439
24685
|
type: "array",
|
|
24440
24686
|
items: { type: "string", enum: ["email", "phone"] },
|
|
24441
|
-
description: "Channels to
|
|
24687
|
+
description: "Channels to find (email, phone). Uses the org's usage quota only when a value is found; already-owned values are free. Empty = free identity tier."
|
|
24442
24688
|
},
|
|
24443
24689
|
max_cost: {
|
|
24444
24690
|
type: "number",
|
|
24445
|
-
description: "
|
|
24691
|
+
description: "Usage cap in internal units; leave it unset (default 100000). What the requested channels need is kept for them; a cap below that is refused, naming the minimum. Never show it as money."
|
|
24446
24692
|
},
|
|
24447
24693
|
request_id: {
|
|
24448
24694
|
type: "string",
|
|
24449
|
-
description: "Recommended idempotency key \u2014 REUSE the same value when retrying the same batch so a retry returns the SAME job instead of
|
|
24695
|
+
description: "Recommended idempotency key \u2014 REUSE the same value when retrying the same batch so a retry returns the SAME job instead of running it again."
|
|
24450
24696
|
},
|
|
24451
24697
|
lang: { type: "string", description: "Output language (default: user's language)." },
|
|
24452
24698
|
confirm: {
|
|
24453
24699
|
type: "boolean",
|
|
24454
|
-
description: "Explicit
|
|
24700
|
+
description: "Explicit go-ahead for the work that uses quota (fresh qualification and/or channels). true = the user approved, go ahead. false = a veto (returns mode:'needs_confirmation', uses nothing). Omitted on such a 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."
|
|
24455
24701
|
},
|
|
24456
24702
|
dry_run: {
|
|
24457
24703
|
type: "boolean",
|
|
24458
|
-
description: "Validate + worst-case
|
|
24704
|
+
description: "Validate + worst-case usage + quota forecast. No job, uses nothing."
|
|
24459
24705
|
},
|
|
24460
24706
|
wait_seconds: {
|
|
24461
24707
|
type: "number",
|
|
@@ -24515,7 +24761,7 @@ var qualifyLeads = {
|
|
|
24515
24761
|
quote: forecast,
|
|
24516
24762
|
estimated_cost: forecast?.estimated_cost ?? null,
|
|
24517
24763
|
items_requested: forecast?.items_requested ?? null,
|
|
24518
|
-
hint: vetoed ? "confirm:false vetoed the
|
|
24764
|
+
hint: vetoed ? "confirm:false vetoed the run \u2014 nothing was submitted. Re-call with confirm:true to proceed, or qualify:false with no channels for a free pass." : "Tell the user what will run and that it uses their plan's quota (no amounts, no money), get an explicit go-ahead, then re-call with confirm:true. For a free pass instead: qualify:false and no channels.",
|
|
24519
24765
|
region: client.region
|
|
24520
24766
|
};
|
|
24521
24767
|
}
|
|
@@ -25116,7 +25362,7 @@ var newLens = {
|
|
|
25116
25362
|
inputSchema: {
|
|
25117
25363
|
type: "object",
|
|
25118
25364
|
properties: {
|
|
25119
|
-
name: { type: "string", description: "Display name for the new lens (required)." },
|
|
25365
|
+
name: { type: "string", maxLength: 255, description: "Display name for the new lens (required)." },
|
|
25120
25366
|
sectors: {
|
|
25121
25367
|
type: "array",
|
|
25122
25368
|
items: { type: "string" },
|
|
@@ -25149,7 +25395,7 @@ var newLens = {
|
|
|
25149
25395
|
type: "number",
|
|
25150
25396
|
description: "Lens id to clone from. Defaults to the active/default lens."
|
|
25151
25397
|
},
|
|
25152
|
-
description: { type: "string", description: "Optional lens description." },
|
|
25398
|
+
description: { type: "string", maxLength: 255, description: "Optional lens description." },
|
|
25153
25399
|
confirm: {
|
|
25154
25400
|
type: "boolean",
|
|
25155
25401
|
description: "Safety gate. Defaults to false \u2192 the tool returns a PREVIEW and creates nothing. Show the preview to the user, get their explicit go-ahead, then re-call the SAME args with confirm:true to actually create the lens."
|
|
@@ -26041,8 +26287,8 @@ var sendFeedback = {
|
|
|
26041
26287
|
|
|
26042
26288
|
// ../core/dist/artifact-runtime.generated.js
|
|
26043
26289
|
var ARTIFACT_KIT_VERSION = "0.5.0";
|
|
26044
|
-
var ARTIFACT_RUNTIME = '"use strict";(()=>{var L=Object.defineProperty;var A=(e,r,t)=>r in e?L(e,r,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[r]=t;var a=(e,r,t)=>A(e,typeof r!="symbol"?r+"":r,t);var _=`\n:root{\n--color-black:#191919;--color-white:#fff;\n--color-gray-1:#f9f9f9;--color-gray-2:#f0f0f0;--color-gray-3:#e0e0e0;--color-gray-4:#cecece;\n--color-gray-5:#c4c4c4;--color-gray-6:#8d8d8d;--color-gray-7:#787878;--color-gray-8:#646464;\n--color-gray-9:#202020;\n--color-linkedin:#0a66c2;\n--color-blue-background:oklch(0.947 0.029 251);--color-blue-foreground:oklch(0.564 0.181 251);\n--color-green-background:oklch(0.947 0.029 141);--color-green-foreground:oklch(0.564 0.181 141);\n--color-red-background:oklch(0.947 0.029 26);--color-red-foreground:oklch(0.564 0.191 26);\n--color-gold-background:oklch(0.972 0.049 91);--color-gold-foreground:oklch(0.667 0.177 91);\n--color-cherry-background:oklch(0.947 0.029 15);--color-cherry-foreground:oklch(0.44 0.146 15);\n--color-red-like:var(--color-cherry-foreground);\n--lb-font:"Nikkei Maru",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;\n--lb-radius:1rem;--lb-radius-sm:0.625rem;--lb-gap:0.75rem;\n--lb-surface:var(--color-gray-1);--lb-border:var(--color-gray-3);\n--lb-fg:var(--color-black);--lb-muted:var(--color-gray-8);--lb-field:var(--color-white);\n}\n:root[data-theme=dark],:root[data-lb-theme=dark]{\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}\n@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-lb-theme=light]){\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);--lb-field:var(--color-gray-9);\n}}\n.lb-card{display:grid;gap:var(--lb-gap);padding:0.875rem;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius);corner-shape:squircle;color:var(--lb-fg);\nfont-family:var(--lb-font);\nbox-shadow:0 1rem 2.5rem color-mix(in srgb,var(--color-gray-9) 6%,transparent),\n0 0.125rem 0.5rem color-mix(in srgb,var(--color-gray-9) 4%,transparent)}\n.lb-card-head{display:flex;justify-content:space-between;align-items:baseline;gap:var(--lb-gap)}\n.lb-title{font-size:0.875rem;font-weight:600;line-height:1.25rem;color:var(--lb-fg)}\n.lb-sub{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-row{display:flex;align-items:center;gap:var(--lb-gap);flex-wrap:wrap}\n.lb-stack{display:grid;gap:var(--lb-gap)}\n.lb-select,.lb-input{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;color:var(--lb-fg);\nbackground-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.55rem;min-height:2.125rem}\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--lb-fg);background-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.85rem;min-height:2.125rem;\ncursor:pointer;transition:background-color .15s,border-color .15s,color .15s}\n.lb-btn:hover:not([disabled]){border-color:var(--color-gray-6)}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible{\noutline:2px solid var(--color-blue-foreground);outline-offset:1px}\n.lb-btn[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-btn[data-lb-state=success]{background-color:var(--color-green-background);\nborder-color:var(--color-green-foreground);color:var(--color-green-foreground)}\n.lb-btn[data-lb-state=error],.lb-select[data-lb-state=error]{\nbackground-color:var(--color-red-background);border-color:var(--color-red-foreground);\ncolor:var(--color-red-foreground)}\n.lb-btn[data-lb-state=unavailable],.lb-btn[disabled]{opacity:.5;cursor:not-allowed}\n.lb-msg{font-size:0.8125rem;line-height:1.125rem;color:var(--lb-muted)}\n.lb-msg[data-tone=error]{color:var(--color-red-foreground)}\n.lb-msg[data-tone=ok]{color:var(--color-green-foreground)}\n.lb-chip{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;\nfont-size:0.75rem;font-weight:600;line-height:1rem;padding:0.125rem 0.5rem;\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;\nbackground-color:var(--color-gray-2);color:var(--lb-muted)}\n.lb-chip[data-status=WANTED]{background-color:var(--color-blue-background);color:var(--color-blue-foreground)}\n.lb-chip[data-status=WON]{background-color:var(--color-green-background);color:var(--color-green-foreground)}\n.lb-chip[data-status=LOST]{background-color:var(--color-red-background);color:var(--color-red-foreground)}\n.lb-chip[data-status=UNWANTED]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chip[data-taste=liked]{background-color:var(--color-cherry-background);color:var(--color-red-like)}\n.lb-chip[data-taste=disliked]{background-color:var(--color-gray-2);color:var(--color-gray-7)}\n.lb-chips{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}\n.lb-chip[hidden]{display:none}\n.lb-table{width:100%;border-collapse:collapse;font-family:var(--lb-font);color:var(--lb-fg)}\n.lb-table th,.lb-table td{text-align:left;padding:0.5rem 0.4rem;\nborder-bottom:1px solid var(--lb-border);vertical-align:middle;font-size:0.8125rem}\n.lb-table th{font-size:0.75rem;font-weight:600;color:var(--lb-muted);\ntext-transform:uppercase;letter-spacing:.04em}\n.lb-link{color:var(--color-blue-foreground);text-decoration:none}\n.lb-link:hover{text-decoration:underline}\n/* Quiet text link out of the artifact. Button-height so it shares the row\'s\n baseline; understated so it never competes with the actions beside it. The\n arrow is a bare diagonal stroke \\u2014 an escape-hatch marker, not an icon that\n asks to be read. */\n.lb-link-out{display:inline-flex;align-items:center;gap:.3rem;\nfont-size:0.75rem;line-height:1rem;min-height:2.125rem;\ncolor:var(--lb-fg);text-decoration:none;opacity:.65;transition:opacity .15s}\n.lb-link-out:hover{opacity:1;text-decoration:underline}\n.lb-link-out:focus-visible{outline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n.lb-link-out svg{width:.85em;height:.85em;flex-shrink:0}\n/* Pushes whatever follows it to the right edge of an .lb-row, so a trailing\n link sits on the SAME baseline as the row\'s buttons instead of on its own\n line below them. */\n.lb-spacer{flex:1 1 auto}\n.lb-spinner{display:inline-block;width:.7em;height:.7em;border:2px solid var(--lb-border);\nborder-top-color:var(--color-blue-foreground);border-radius:50%;animation:lb-spin .8s linear infinite}\n@keyframes lb-spin{to{transform:rotate(1turn)}}\n@media(prefers-reduced-motion:reduce){.lb-spinner{animation:none}\n.lb-btn{transition-property:none}}\n`,p="lb-styles";var x="0.5.0",c=class extends Error{constructor(t,o={}){super(t);a(this,"code");a(this,"raw");this.name="LbError",this.code=o.code,this.raw=o.raw}},y=null,f=3e4;function C(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(r,t)=>e.callMcpTool(r,t):null}function S(e){if(e&&typeof e=="object"&&"content"in e){let r=e.content;if(Array.isArray(r)&&r[0]&&typeof r[0].text=="string")return r[0].text}return null}function E(e){if(!e||typeof e!="object")return e;let r=e;if(r.isError)throw new c(S(e)??"tool call failed",{raw:e});if("structuredContent"in r&&r.structuredContent!=null)return r.structuredContent;let t=S(e);if(t!=null)try{return JSON.parse(t)}catch{return t}return e}function I(e){return e instanceof Error?e.message:String(e)}function v(e){let r=e instanceof c?e.code:void 0;return{message:I(e),unavailable:r==="unavailable",code:r}}function O(e={}){y=e.call??null,f=e.timeoutMs??3e4}function R(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(p);if(e)return e;let r=document.createElement("style");return r.id=p,r.textContent=_,document.head.appendChild(r),r}async function T(e,r){if(!f||f<=0)return e;let t,o=new Promise((n,i)=>{t=setTimeout(()=>i(new c(`"${r}" timed out after ${f}ms`,{code:"timeout"})),f)});try{return await Promise.race([e,o])}finally{t&&clearTimeout(t)}}async function l(e,r={}){if(y)return E(await T(Promise.resolve(y(e,r)),e));let t=C();if(!t)throw new c("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});return E(await T(Promise.resolve(t(e,r)),e))}var g=class{constructor(){a(this,"subs",new Set)}subscribe(r){return this.subs.add(r),r(this),()=>this.subs.delete(r)}emit(){for(let r of this.subs)r(this)}};function N(e){return Array.isArray(e)?e.map(r=>r&&typeof r=="object"?r:{value:r,label:String(r)}):[]}var b=class extends g{constructor(t={}){super();a(this,"kind");a(this,"value");a(this,"options",[]);a(this,"loading",!1);a(this,"error",null);a(this,"ready",!1);a(this,"cfg");a(this,"depUnsubs",[]);a(this,"seq",0);this.cfg=t,this.kind=t.kind,this.value=t.value??"";for(let o of t.dependsOn??[]){let n=o.value;this.depUnsubs.push(o.subscribe(()=>{o.value!==n&&(n=o.value,this.cfg.load&&this.load())}))}t.load&&(t.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let t=++this.seq;this.loading=!0,this.error=null,this.emit();try{let o=await this.cfg.load();if(t!==this.seq)return;this.options=this.cfg.options?this.cfg.options(o):N(o),this.ready=!0;let n=this.value==null?"":String(this.value);this.options.length&&(n===""||!this.options.some(i=>String(i.value)===n))&&(this.value=this.options[0].value)}catch(o){if(t!==this.seq)return;this.options=[],this.error=v(o)}finally{t===this.seq&&(this.loading=!1,this.emit())}}setValue(t){this.value=t;let o=this.validate();this.error=o?{message:o,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let t of this.depUnsubs)t();this.depUnsubs=[]}};function P(e){if(!e||typeof e!="object")return null;let r=e;if(r.error!==!0)return null;let t=typeof r.message=="string"&&r.message?r.message:"tool call failed",o=typeof r.hint=="string"&&r.hint?` \\u2014 ${r.hint}`:"";return`${t}${o}`}var s=class extends g{constructor(t){super();a(this,"loading",!1);a(this,"error",null);a(this,"lastResult",null);a(this,"cfg");this.cfg=t}async run(){if(this.loading)return;for(let n of this.cfg.fields??[]){let i=n.validate();if(i!=null){this.error={message:i,unavailable:!1},this.emit();return}}if(this.cfg.confirm&&typeof globalThis.confirm=="function"&&!globalThis.confirm(this.cfg.confirm))return;this.loading=!0,this.error=null,this.emit();let t;try{let n=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};t=await l(this.cfg.tool,n)}catch(n){this.error=v(n),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let o=P(t)??this.cfg.checkResult?.(t)??null;if(o!=null){this.error={message:o,unavailable:!1},this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}return this.lastResult=t,this.loading=!1,this.emit(),this.cfg.onSuccess?.(t),t}reset(){this.error=null,this.lastResult=null,this.emit()}},u=class extends g{constructor(t){super();a(this,"data",null);a(this,"loading",!1);a(this,"refreshing",!1);a(this,"error",null);a(this,"done",!1);a(this,"cfg");a(this,"timer",null);a(this,"seq",0);this.cfg=t,(t.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let t=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let n=await this.cfg.load();if(t!==this.seq)return;this.data=n,this.done=this.cfg.until?this.cfg.until(n):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(n){if(t!==this.seq)return;this.error=v(n)}finally{t===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},h=class extends g{constructor(t){super();a(this,"items",[]);a(this,"page",0);a(this,"pageSize");a(this,"total",0);a(this,"loading",!1);a(this,"error",null);a(this,"cfg");a(this,"seq",0);this.cfg=t,this.pageSize=t.pageSize??20,(t.autoLoad??!0)&&this.loadPage(0)}async loadPage(t){let o=++this.seq;this.loading=!0,this.error=null,this.emit();try{let n=await this.cfg.load({page:t,pageSize:this.pageSize});if(o!==this.seq)return;this.items=n.items??[],this.total=n.total??this.items.length,this.page=t}catch(n){if(o!==this.seq)return;this.error=v(n)}finally{o===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function M(e,r){let t=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":"ready";e.setAttribute("data-lb-state",t),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")}function D(e,r){let t=()=>r.setValue(e.value);e.addEventListener("change",t);let o=r.subscribe(()=>{M(e,r),e.disabled=r.loading,e.innerHTML="";for(let n of r.options){let i=document.createElement("option");i.value=String(n.value),i.textContent=n.label,e.appendChild(i)}e.value=r.value==null?"":String(r.value)});return()=>{e.removeEventListener("change",t),o()}}function z(e,r){let t=e.type==="checkbox",o=e.tagName==="SELECT"?"change":"input",n=()=>r.setValue(t?e.checked:e.value);e.addEventListener(o,n);let i=r.subscribe(()=>{if(t)e.checked=!!r.value;else{let d=r.value==null?"":String(r.value);e.value!==d&&(e.value=d)}e.setAttribute("data-lb-state",r.error?"error":"ready"),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(o,n),i()}}function F(e,r){let t=n=>{n.preventDefault(),r.run()};e.addEventListener("click",t);let o=r.subscribe(()=>{let n=r.error?.unavailable?"unavailable":r.loading?"loading":r.error?"error":r.lastResult!=null?"success":"idle";e.setAttribute("data-lb-state",n),"disabled"in e&&(e.disabled=r.loading),r.error?e.setAttribute("data-lb-error",r.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener("click",t),o()}}var q=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],k=[{value:"",label:"Default ranking"},{value:"SCORE:DESC",label:"Score \\u2193"},{value:"SCORE:ASC",label:"Score \\u2191"},{value:"NAME:ASC",label:"Name A\\u2192Z"},{value:"NAME:DESC",label:"Name Z\\u2192A"},{value:"SIZE:DESC",label:"Size \\u2193"},{value:"SIZE:ASC",label:"Size \\u2191"},{value:"SECTOR:ASC",label:"Sector A\\u2192Z"},{value:"STATUS:ASC",label:"Status A\\u2192Z"},{value:"CONTACT_COUNT:DESC",label:"Contacts \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:DESC",label:"Last action \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:ASC",label:"Last action \\u2191"},{value:"EPILOGUE_STATUS_SET_AT:DESC",label:"Outcome set \\u2193"},{value:"LIKED:DESC",label:"Liked first"},{value:"DISLIKED:DESC",label:"Disliked first"}];function U(e){let r=String(e??"").trim().toUpperCase(),t=k.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",load:async()=>k.slice()})}var m=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],H={value:"",label:"\\u2014 Not set \\u2014"};function j(e){let r=String(e??"").trim().toUpperCase(),t=m.some(o=>o.value===r);return new b({kind:"select",value:t?r:"",validate:o=>String(o??"")===""?"Pick a status":null,load:async()=>t?m.slice():[H,...m]})}function W(e){let r=()=>{let t=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(t)?t:e.leadId?[e.leadId]:[]};return new s({tool:"leadbay_set_lead_status",fields:e.date?[e.status,e.date]:[e.status],confirm:e.confirm,args:()=>({lead_ids:r(),status:e.status.value,...e.date&&e.date.value?{status_date:e.date.value}:{},...e.ask?{_triggered_by:e.ask}:{}}),checkResult:t=>{let o=t?.failed;if(!Array.isArray(o)||o.length===0)return null;let n=r().length,i=o[0]?.message??"write rejected";return o.length===n?`Status not applied: ${i}`:`${o.length} of ${n} leads failed: ${i}`}})}function $(e){return new b({kind:"select",load:()=>l("leadbay_list_campaigns",{_triggered_by:e}),options:r=>(r?.campaigns??[]).map(o=>{let n=o?.campaign??o;return n?.id?{value:n.id,label:n.name??n.ai_generated_name??String(n.id)}:null}).filter(o=>o!=null)})}function V(e){return new s({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function G(e){return new s({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function Z(e){return new s({tool:"leadbay_like_lead",args:{lead_id:e}})}function Y(e){return new s({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function B(e,r){return new u({autoLoad:!1,load:()=>l("leadbay_account_history",{leadId:e,_triggered_by:r})})}function K(e,r){return new u({autoLoad:!1,load:()=>l("leadbay_research_lead_by_id",{leadId:e,_triggered_by:r})})}function J(e){let r=null;return new u({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:t=>!!t?.all_done,load:async()=>{if(!r){let t=await l("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask}),o=Array.isArray(t?.lead_ids)?t.lead_ids:[],n=t?.notification_id??null;if(r=n||o.length>0?{notification_id:n,lead_ids:o}:null,!r)return{...t,all_done:!0,no_job:!0}}return l("leadbay_bulk_enrich_status",{...r.notification_id?{notification_id:r.notification_id}:{},...r.lead_ids.length>0?{lead_ids:r.lead_ids}:{},...e.titles?{titles:e.titles}:{},...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},_triggered_by:e.ask})}})}function Q(e){let r=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:t,pageSize:o})=>{let n=await l("leadbay_pull_leads",{page:t,count:o,...e.lensId?{lensId:e.lensId}:{},...r()?{order:r()}:{},_triggered_by:e.ask}),i=n.leads??[];return{items:i,total:n.pagination?.total??i.length}}})}function X(e){let r=e.source??"followups",t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new h({pageSize:e.pageSize??20,load:async({page:o,pageSize:n})=>{let d=r==="campaign"?await l("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:o,count:n,_triggered_by:e.ask}):await l("leadbay_pull_followups",{page:o,count:n,...e.city?{city:e.city}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),w=d.leads??d.items??[];return{items:w,total:d.total_leads??d.pagination?.total??w.length}}})}function ee(e){return new u({load:()=>l("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var re={VERSION:x,configure:O,styles:R,call:l,field:e=>new b(e),action:e=>new s(e),resource:e=>new u(e),list:e=>new h(e),bindSelect:D,bindValue:z,bindAction:F,campaigns:$,outreach:V,note:G,like:Z,dislike:Y,leadStatus:j,setStatus:W,sortOrder:U,leadHistory:B,leadProfile:K,enrichment:J,callList:X,leadList:Q,teamActivity:ee,EPILOGUE_STATUSES:q,LEAD_STATUSES:m,SORT_ORDERS:k};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=re);})();';
|
|
26045
|
-
var ARTIFACT_USAGE_GUIDE = '# Leadbay Artifact Kit \u2014 headless domain components\n\nYou are building a single-file HTML **artifact** the user runs inside cowork. This\nkit gives you **headless view-models** that own a control\'s whole data lifecycle \u2014\nload/populate from a Leadbay call, hold value/state, poll, validate, and\nencapsulate the API call + business rules. **You own 100% of markup/layout/style.**\nThe library renders nothing. Inline the runtime once as a `<script>`; it exposes\none global `window.LeadbayArtifacts` (call it `lb`). Vanilla, no React, no build.\n\nPass every tool you use as the artifact\'s `mcp_tools` so the host permits it.\n\n## Two layers\n\n**Primitives** (generic):\n- `lb.field({ load, options, value, validate, dependsOn })` \u2014 a value + optionally\n API-populated options. `.value/.setValue/.options/.loading/.error/.valid/.subscribe`.\n- `lb.action({ tool, args, fields, confirm, onSuccess, onError })` \u2014 a write/submit.\n `.run()/.loading/.error/.lastResult/.subscribe`.\n- `lb.resource({ load, pollEvery?, until?, autoLoad? })` \u2014 one read that may change:\n load-on-click or poll-until-`until`. `.data/.loading/.refreshing/.error/.done/.load()/.refresh()/.stop()/.subscribe`.\n- `lb.list({ load, pageSize })` \u2014 paginated rows. `.items/.page/.total/.loading/.loadPage(n)/.next()/.prev()/.hasMore/.subscribe`.\n\n`.error` is `{ message, unavailable } | null`. `subscribe(cb)` fires immediately\nthen on every change \u2014 render your own DOM from it.\n\n**Domain components** (pre-wired \u2014 bake in the tool name, arg shape, and footguns):\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.campaigns(ask)` | field | a campaign `<select>`, options from `leadbay_list_campaigns` |\n| `lb.outreach({leadId, ask, status?, note?})` | action | log a call \u2192 `report_outreach` (verification + `_triggered_by` baked in) |\n| `lb.note({leadId, note})` | action | add a note \u2192 `add_note` |\n| `lb.like(leadId)` / `lb.dislike(leadId)` | action | taste signal |\n| `lb.leadStatus(current?)` | field | a status `<select>` (Wanted/Won/Lost/Unwanted) |\n| `lb.setStatus({leadId or leadIds, status, date?, ask})` | action | write the org CRM status \u2192 `set_lead_status` |\n| `lb.leadHistory(leadId, ask)` | resource (lazy) | notes + activities + engagement \u2192 `account_history` |\n| `lb.leadProfile(leadId, ask)` | resource (lazy) | full lead profile \u2192 `research_lead_by_id` |\n| `lb.sortOrder(current?)` | field | a sort `<select>` mirroring the app\'s TableSort |\n| `lb.leadList({lensId?, order?, ask})` | list | a sortable Discover batch \u2192 `pull_leads` |\n| `lb.callList({source:\'followups\'\\|\'campaign\', campaignId?, city?, ask})` | list | a cold-call list (Monitor or a campaign) |\n| `lb.enrichment({leadIds, titles, ask, pollEvery?})` | resource (polling) | launch + watch contact enrichment |\n| `lb.teamActivity({weeks, ask})` | resource | manager leaderboard + activity trend \u2192 `leadbay_team_activity` |\n\n`lb.EPILOGUE_STATUSES` = the 4 disposition values\n(`STILL_CHASING`, `COULD_NOT_REACH_STILL_TRYING`, `INTEREST_VALIDATED_OR_MEETING_PLANED`, `NOT_INTERESTED_LOST`).\n`lb.LEAD_STATUSES` = the 4 org CRM statuses as `{value,label}` (`WANTED`, `WON`, `LOST`, `UNWANTED`).\n`lb.SORT_ORDERS` = the sort options as `{value,label}`; values are the backend `FIELD:ASC|DESC` enum.\n\n**Sorting is a SERVER concern.** `lb.leadList` and `lb.callList` take an `order`\n(a `lb.sortOrder()` field or a literal) and send it upstream; the backend sorts\nthe whole lens / Monitor and returns the requested page of that. Never re-sort\nrows in the browser \u2014 you would be reordering one page of a larger set, showing\nleads that do not belong at that position. The empty value means "no order\nparam", i.e. the tab\'s own ranking, which is the right default. Changing the\nsort should reset to page 0. Campaign call sheets cannot sort:\n`leadbay_campaign_call_sheet` has no `order` param, and `lb.callList` drops it\nfor that source rather than sending something the tool would reject.\n\n**Two different systems.** Epilogue = how one outreach attempt went (drives\nfollow-up ranking). Lead status = the commercial outcome, org-wide \u2014 the same\nfield the website\'s status selector writes. A won deal is a LEAD STATUS;\n"she didn\'t pick up" is an EPILOGUE. Setting one never sets the other, so when\nthe user reports both in one breath, fire both actions.\n\n**Binding sugar** (optional; binds a view-model to YOUR native element, no style):\n`lb.bindSelect(selectEl, field)` (populates options + value), `lb.bindValue(inputEl, field)`,\n`lb.bindAction(buttonEl, action)`. They set `data-lb-state`\n(`ready|loading|error|success|unavailable`) + `data-lb-error` on your element as\nstyling hooks. For lists/resources, use `.subscribe()` and render yourself.\n\n`ask` is the user\'s request this artifact serves \u2014 it becomes `_triggered_by`.\n\n## The skin (optional) \u2014 `lb.styles()`\n\nCall it once and you get a small `lb-*` stylesheet, so every artifact you build\nshares one visual language instead of re-inventing padding and colours. It is\n**opt-in**: skip it and you get exactly the unstyled HTML you wrote. It injects\nno markup and never touches your `class` attributes.\n\n```js\nlb.styles(); // idempotent \u2014 safe to call per row\n```\n\n| Class | For |\n|---|---|\n| `lb-card` / `lb-card-head` / `lb-title` / `lb-sub` | a lead card + its header |\n| `lb-row` / `lb-stack` / `lb-spacer` | control row / vertical spacing / flex filler that right-aligns what follows |\n| `lb-link-out` | quiet external link (icon inherits currentColor) \u2014 "Open in Leadbay" |\n| `lb-select` / `lb-input` / `lb-btn` | form controls (state-aware, see below) |\n| `lb-msg` (`data-tone="error\\|ok"`) | inline feedback |\n| `lb-chip` (`data-status="WON\\|LOST"`) | a status pill |\n| `lb-table` | leads table |\n| `lb-spinner` | inline busy indicator |\n\nControls react to the `data-lb-state` the bind helpers already set \u2014 a bound\n`lb-btn` dims while loading, goes green on success, red on error, all with no\nextra CSS from you.\n\nThe palette is the **product design system**, ported from\n`frontend/packages/style/color.css` \u2014 same `--color-gray-1\u20269` ramp, same\nsemantic `--color-{green,red,blue,gold}-{background,foreground}` pairs, same\n`1rem` / `0.625rem` radii and `corner-shape: squircle` as the app\'s components.\nAn artifact therefore looks like Leadbay, not like a generic page.\n\nUse the tokens rather than hardcoded colours \u2014 the same rule the style package\nenforces. Re-theme by overriding them; don\'t fight specificity:\n\n```css\n:root { --lb-surface: var(--color-gray-2); --lb-radius: 0.5rem; }\n```\n\nDark mode works two ways: `data-theme="dark"` on `<html>` (the frontend\'s own\nhook) **and** `prefers-color-scheme`, because an artifact renders inside a host\nwhose theme attribute it cannot set. Never hardcode a light background over the\nskin.\n\nThe product face is `Nikkei Maru`; the stack names it first and falls back to\nthe system UI font. Do **not** add an `@font-face` \u2014 artifacts are inline-only\nand a remote font URL will silently fail.\n\n## What every lead card MUST carry\n\nA card is the artifact form of the `pull_leads` table, and it inherits that\ntable\'s rules. A card with a name and a button is not enough: the rep cannot\ntell *why* this lead is on screen. Four lines, in this order.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title"></span> <!-- 1. company -->\n <span class="lb-chips"> <!-- 2. state -->\n <span class="lb-chip" data-taste hidden></span>\n <span class="lb-chip" data-status hidden></span>\n </span>\n </div>\n <div class="lb-sub"></div> <!-- 3. firmographics -->\n <div class="lb-sub" data-why></div> <!-- 4. why it fits -->\n <div class="lb-row"><!-- actions --></div>\n</div>\n```\n\n1. **Company** \u2014 `name`, linked to `website` (prefix `https://` on a bare host).\n Never render the numeric `score`; use the `\u25B0\u2756\u25B1` bar if you want the signal.\n\n Also give every card an **Open in Leadbay** link to the lead\'s panel in the\n product. Put it at the **right-hand end of the card\'s last action row** \u2014\n same row as the buttons, pushed right by an `lb-spacer`, not on a line of\n its own. Style it `lb-link-out`: quiet text plus a plain arrow-up-right,\n never a filled button. It is an escape hatch, not a call to action.\n\n ```html\n <div class="lb-row">\n <button class="lb-btn">Like</button>\n <button class="lb-btn">Set status</button>\n <span class="lb-spacer"></span> <!-- pushes the link right -->\n <a class="lb-link-out" data-k="open" target="_blank" rel="noopener">\n Open in Leadbay\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/>\n </svg>\n </a>\n </div>\n ```\n\n Keep the arrow a bare diagonal stroke \u2014 the text already says where the link\n goes, so the glyph only has to mark "leaves this page". Mark the `<svg>`\n `aria-hidden="true"`: it is decorative, and the link text is the accessible\n name.\n **Pick the view the lead actually lives in** \u2014 the URL is\n `/app/<view>?lead=<uuid>`, and the three views are `discover`, `monitor`,\n `campaign`. Landing a Monitor lead on Discover drops the rep into a list\n that does not contain it:\n\n ```js\n function leadUrl(lead, campaignId) {\n const id = encodeURIComponent(lead.id);\n // A campaign card carries TWO params \u2014 the campaign selects the list, the\n // lead opens the panel inside it. Campaign wins even when in_monitor is\n // also true, because that is the list the rep is looking at.\n if (campaignId) {\n return `https://leadbay.app/app/campaign?campaign=${encodeURIComponent(campaignId)}&lead=${id}`;\n }\n const view = lead.in_monitor ? "monitor" : "discover";\n return `https://leadbay.app/app/${view}?lead=${id}`;\n }\n openEl.href = leadUrl(lead, campaignId);\n ```\n\n `in_monitor` / `in_discover` are booleans on the `pull_followups` payload \u2014\n every follow-up carries `in_monitor: true`, so a call sheet must link to\n `monitor`. `pull_leads` omits both flags entirely; its leads are the Discover\n batch by definition, so `discover` is the default. A campaign card\n (`lb.callList({source:"campaign", campaignId})`) needs `?campaign=<id>&lead=<id>`\n \u2014 the param names are `CAMPAIGN_QUERY_PARAM` and `LEAD_QUERY_PARAM`, and the\n app\'s own `useLeadPanel` preserves whatever params are already set, so the\n two coexist by design. Omitting `campaign=` opens an empty campaign view.\n\n Inline the glyph as SVG rather than an emoji or `\u2197` \u2014 it inherits\n `currentColor` and scales with the text, so it stays legible in both themes.\n `?lead=<uuid>` is the real deep-link (`LEAD_QUERY_PARAM` in the web app, read\n on load; the panel is an overlay, so the view choice only decides what sits\n behind it). This is the ONE place a card may use `lead.id`: as a link target,\n never as visible text.\n2. **State chips** \u2014 taste (`data-taste`) and CRM status (`data-status`) are\n INDEPENDENT axes; render both, hide the empty one. Never collapse to one chip.\n3. **Firmographics** \u2014 sector of activity first, then city, then size, then the\n contact. `sector_id` is a RAW ID (`"5136"`), not a label: resolve it via\n `leadbay_list_sectors` (1346 rows \u2014 fetch once, cache, never inline the lot)\n or omit it. Never print the raw id.\n\n **Always show whether the lead is reachable \u2014 and never merge the person\n with the company\'s switchboard.** These are two separate lines:\n\n ```js\n // WHO \u2014 recommended_contact. Name, and job_title ONLY when present; on list\n // payloads it is usually null, and inventing one is worse than omitting it.\n const rc = lead.recommended_contact;\n const who = rc ? [rc.first_name, rc.last_name].filter(Boolean).join(" ") : null;\n const whoLine = who ? who + (rc.job_title ? " \xB7 " + rc.job_title : "") : "No named contact";\n\n // HOW \u2014 company-level channels. `phone_numbers` and `email` belong to the\n // COMPANY, not to `recommended_contact`. Rendering "Jean \xB7 \u260E 0123\u2026" claims a\n // direct line that does not exist; it is the switchboard.\n const phone = (lead.phone_numbers || [])[0] || null;\n // The API returns the STRING "null" for a missing email \u2014 guard for it or\n // you will print the word "null" as an address.\n const email = lead.email && lead.email !== "null" ? lead.email : null;\n const howLine = [phone && "\u260E " + phone, email && "\u2709 " + email].filter(Boolean)\n .join(" \xB7 ") || "No direct channel \u2014 enrich to reveal";\n ```\n\n ```html\n <div class="lb-sub">Sector \xB7 City \xB7 Size</div>\n <div class="lb-sub">\u{1F464} Jean-Fran\xE7ois Froemer \xB7 G\xE9rant</div> <!-- WHO -->\n <div class="lb-sub">\u{1F3E2} \u260E 01 23 45 67 89</div> <!-- HOW: company -->\n ```\n\n Label the channel line as the **company\'s**, so a rep reading fast cannot\n mistake it for a direct line. A per-contact email or phone exists only after\n enrichment \u2014 `research_lead_by_id` exposes it as `contacts.reachable[]`, and\n `_meta.has_reachable_contact` is the authoritative flag. The list payloads\n carry neither, so a card built from `pull_leads` / `pull_followups` can only\n ever show company channels. Say "enrich to reveal" rather than implying the\n contact is callable.\n\n Two things that look like reachability and are not: a `linkedin_page` alone\n (the rep cannot message a URL without leaving the artifact \u2014 same rule\n `research_lead_by_id` applies), and `contacts_count > 0` (it counts known\n people, not people you can contact; a lead can show 2518 contacts and zero\n channels). `pull_followups` carries `has_phone` as a ready-made boolean;\n `pull_leads` omits it, so derive from `phone_numbers` there.\n4. **Why it fits** \u2014 one sentence, \u226420 words. Walk this chain and stop at the\n first hit:\n\n 1. `short_description`\n 2. `description` (longer; only on `research_lead_by_id` /\n `research_lead_by_name_fuzzy` \u2014 the trim payloads omit it)\n 3. top 2 `tags[].display_name`\n 4. `qualification_summary.best_response_excerpt`, trimmed to one sentence\n 5. `keywords`, first 3, joined with ` \xB7 `\n 6. the resolved sector label \u2014 better than nothing, and if step 3 already\n printed the sector on the firmographics line, skip to step 7\n 7. the literal *"No description yet \u2014 run qualification to generate one"*\n\n Never leave this line blank: a silent gap reads as a rendering bug, whereas\n the fallback tells the rep the data is missing and what fixes it.\n\n **The two list payloads are complementary, so the chain must span both.**\n `pull_leads` returns `short_description` on every lead but no `sector_id`;\n `pull_followups` returns `sector_id` but no `short_description` at all. A\n card fed by one will fall through to a different step than the same card fed\n by the other \u2014 that is expected, not a bug. Never call\n `research_lead_by_id` per row just to fill this line: it is one request per\n lead. Fetch it lazily when the rep expands a card.\n\n**Never show** on a card: `id`, `sector_id`, `location.pos`, `location.country`\n(unless city and state are both missing), `is_hq`, `*_in_progress`,\n`highlighted_fields`, `custom_fields`, `stale_at`, `deal_insights`,\n`need_attention*`, any count that is 0, any value that is the string `"null"`.\n\n**Minimum actions.** A card that only displays is a table row that costs more \u2014\nif you are not wiring an action, render the markdown table instead. Wire at\nleast one write, and prefer the set the rep actually needs:\n\n| Card is for | Wire |\n|---|---|\n| triage a discovery batch | `lb.like` / `lb.dislike` + `lb.setStatus` |\n| working a call list | `lb.outreach` (gated on a note) + `lb.leadHistory` |\n| pipeline review | `lb.setStatus` + `lb.note` |\n\nAlways render the `.error` branch of every view-model \u2014 a control that cannot\nreach the host must say so, not sit silent.\n\n## Recipe: cold-call sheet (one row per lead)\n\n```js\nconst lb = window.LeadbayArtifacts; lb.configure();\nconst ASK = "<the user\'s request>";\n\nconst list = lb.callList({ source: "campaign", campaignId: CID, ask: ASK });\nlist.subscribe((l) => renderRows(l.items, l.loading)); // your render\n\n// per lead row (call when you build a row):\nfunction wireRow(lead, els) {\n const status = lb.field({ value: "STILL_CHASING" }); // static-enum <select>\n const note = lb.field({ validate: (v) => (v && v.trim() ? null : "Add a note") });\n lb.bindValue(els.status, status);\n lb.bindValue(els.note, note);\n lb.bindAction(els.log, lb.outreach({ leadId: lead.id, ask: ASK, status, note }));\n lb.bindAction(els.like, lb.like(lead.id));\n\n const history = lb.leadHistory(lead.id, ASK); // lazy\n history.subscribe((h) => renderHistory(els.history, h));\n els.expand.onclick = () => history.load(); // load on click\n}\n```\n\n## Recipe: lead-status dropdown (Wanted / Won / Lost)\n\nThe org-wide CRM status, as a `<select>` + Apply button. You write the markup;\n`lb.leadStatus` fills the options and holds the value, `lb.setStatus` does the write.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title">Acme Corp</span>\n <span class="lb-chips">\n <span id="taste" class="lb-chip" data-taste="liked">Liked</span>\n <span id="crm" class="lb-chip" data-status="WANTED">Wanted</span>\n </span>\n </div>\n <div class="lb-row">\n <select id="st" class="lb-select"></select>\n <button id="go" class="lb-btn">Apply</button>\n <span id="msg" class="lb-msg"></span>\n </div>\n</div>\n```\n\n**Two badges, never one.** Taste (`liked`/`disliked`, from `lb.like`/`lb.dislike`)\nand CRM status (`WANTED`/`WON`/`LOST`/`UNWANTED`, from `lb.setStatus`) are\nindependent axes \u2014 a lead can be liked *and* lost. Collapsing them into a single\nchip destroys information: the rep can no longer see that a lead they liked went\nnowhere. Render `data-taste` and `data-status` as separate chips inside\n`lb-chips`, and hide the one that has no value rather than reusing it.\n\n```js\nlb.styles(); // once per artifact \u2014 see below\n\nconst status = lb.leadStatus(lead.org_lead_status); // seed with the CURRENT value\nconst save = lb.setStatus({ leadId: lead.id, status, ask: ASK });\n\nlb.bindSelect(document.getElementById("st"), status); // populates the 4 options\nlb.bindAction(document.getElementById("go"), save); // click \u2192 write\n\nsave.subscribe((a) => { // render your own feedback\n msg.textContent = a.loading ? "Saving\u2026"\n : a.error ? a.error.message // includes partial failures\n : a.lastResult ? `Set to ${a.lastResult.status}` : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nLoading / success / error styling comes free: `bindAction` and `bindSelect` set\n`data-lb-state` (`ready|loading|error|success|unavailable`) and the skin already\ntargets those attributes. No extra wiring.\n\nSave-on-change instead of an Apply button \u2014 drop `bindAction` and run it yourself:\n\n```js\ndocument.getElementById("st").addEventListener("change", () => save.run());\n```\n\n**Bulk apply** across checked rows \u2014 pass `leadIds` and a `confirm`, since one\nclick rewrites a field every rep in the org sees:\n\n```js\nconst bulk = lb.setStatus({\n leadIds: () => checkedIds, // \u2190 read at run() time, not at build time\n status, ask: ASK,\n confirm: "Set this status on every selected lead?",\n});\n```\n\n`leadIds` is read when the action runs, so a live selection works \u2014 but pass the\narray itself if your selection is fixed. A partial write (some leads rejected)\nsurfaces as `.error`, never as a green button: `setStatus` checks the `failed[]`\nthe tool returns.\n\nThe backend stamps the status date as "now" on every write, which is what a rep\nclicking a dropdown means. Don\'t add a date picker unless the user asks to\nbackdate \u2014 then pass an optional `date` field holding `YYYY-MM-DD`:\n`lb.setStatus({ leadId, status, date, ask })`.\n\n## Recipe: manager dashboard\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.loading) showSpinner();\n if (t.data) {\n renderLeaderboard(t.data.reps); // sorted by total_activities; cols: name, notes, meetings_or_interest, lost\u2026\n renderTrendChart(t.data.trend); // [{date,count}] \u2192 Chart.js (allowed from CDN)\n }\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\n## Recipe: live enrichment\n\n```js\nconst job = lb.enrichment({ leadIds: [LEAD], titles: ["CEO", "VP Sales"], ask: ASK });\njob.subscribe((j) => {\n const p = j.data && j.data.overall_progress; // {done,total,done_ratio}\n renderBar(p);\n if (j.done) renderContacts(j.data.leads); // enriched contacts\n});\nrefreshBtn.onclick = () => job.refresh();\n```\n\n## Write-call rules\n\nThe domain factories handle these for you. If you hand-roll an action:\n`leadbay_report_outreach` args MUST include `verification:{source:"user_confirmed", ref}`\nAND `_triggered_by`; `leadbay_add_leads_to_campaign` needs `_triggered_by`;\n`add_note`/`like_lead`/`dislike_lead` take only their own args. `epilogue_status` is\none of `lb.EPILOGUE_STATUSES`. Snoozing (pushback) is advanced-gated \u2014 not\ncallable from a default artifact. Org lead status IS on the default surface:\nuse `lb.setStatus`, which owns the arg shape AND the partial-write check \u2014\n`leadbay_set_lead_status` writes each lead separately, so it can resolve 200\nwith a non-empty `failed[]`. Hand-rolling that action will report a green\nbutton over a write that never landed.\n\n## Degradation + live updates\n\nIf the host bridge is absent, a view-model\'s `.error` is set with `.error.unavailable\n=== true` (bind helpers set `data-lb-state="unavailable"`) \u2014 nothing throws. Every\ncall also has a **30s timeout** (configurable via `lb.configure({ timeoutMs })`): a\nhost call that never settles becomes `.error` with `code:"timeout"`, so a control is\nnever stuck loading forever \u2014 always render the `.error` branch so the user can retry.\nAuto-poll (`pollEvery`) depends on the cowork host serving FRESH reads; `.refresh()`\nis the guaranteed manual path \u2014 always wire a Refresh control for polling resources.';
|
|
26290
|
+
var ARTIFACT_RUNTIME = '"use strict";(()=>{var E=Object.defineProperty;var A=(e,t,r)=>t in e?E(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var a=(e,t,r)=>A(e,typeof t!="symbol"?t+"":t,r);var S=`\n:root{\n--color-black:#191919;--color-white:#fff;\n--color-gray-1:#f9f9f9;--color-gray-2:#f0f0f0;--color-gray-3:#e0e0e0;--color-gray-4:#cecece;\n--color-gray-5:#c4c4c4;--color-gray-6:#8d8d8d;--color-gray-7:#787878;--color-gray-8:#646464;\n--color-gray-9:#202020;\n--color-linkedin:#0a66c2;\n--color-blue-background:oklch(0.947 0.029 251);--color-blue-foreground:oklch(0.564 0.181 251);\n--color-green-background:oklch(0.947 0.029 141);--color-green-foreground:oklch(0.564 0.181 141);\n--color-red-background:oklch(0.947 0.029 26);--color-red-foreground:oklch(0.564 0.191 26);\n--color-gold-background:oklch(0.972 0.049 91);--color-gold-foreground:oklch(0.667 0.177 91);\n--color-cherry-background:oklch(0.947 0.029 15);--color-cherry-foreground:oklch(0.44 0.146 15);\n--color-red-like:var(--color-cherry-foreground);\n--lb-font:"Nikkei Maru",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;\n/* Concentric: outer = inner + padding (0.625 + 0.875 = 1.5rem). At 1rem the\n inner control\'s arc outran the card\'s in the corner. */\n--lb-radius:1.5rem;--lb-radius-sm:0.625rem;--lb-gap:0.75rem;\n--lb-surface:var(--color-gray-1);--lb-border:var(--color-gray-3);\n--lb-fg:var(--color-black);--lb-muted:var(--color-gray-8);--lb-field:var(--color-white);\n/* Chips and shadows need their own aliases: a chip painted with a raw ramp\n value cannot theme, and a shadow tinted with --color-gray-9 is invisible in\n dark because gray-9 IS the dark surface. */\n--lb-chip-bg:var(--color-gray-2);\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.06),0 0.125rem 0.5rem oklch(0 0 0/.04);\ncolor-scheme:light;\n}\n:root[data-theme=dark],:root[data-lb-theme=dark]{\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);\n/* --lb-field must NOT equal --lb-surface: a control needs its own ground or it\n reads as text on the card (was 1.00:1). */\n--lb-field:#2c2c2c;\n--lb-chip-bg:#3b3b3b;\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.5),0 0.125rem 0.5rem oklch(0 0 0/.35);\ncolor-scheme:dark;\n--color-blue-background:oklch(0.30 0.055 251);--color-blue-foreground:oklch(0.80 0.11 251);\n--color-green-background:oklch(0.30 0.055 141);--color-green-foreground:oklch(0.80 0.13 141);\n--color-red-background:oklch(0.30 0.060 26);--color-red-foreground:oklch(0.80 0.11 26);\n--color-gold-background:oklch(0.30 0.055 91);--color-gold-foreground:oklch(0.82 0.13 91);\n--color-cherry-background:oklch(0.30 0.055 15);--color-cherry-foreground:oklch(0.82 0.10 15);\n}\n@media(prefers-color-scheme:dark){:root:not([data-theme=light]):not([data-lb-theme=light]){\n--lb-surface:var(--color-gray-9);--lb-border:var(--color-gray-8);\n--lb-fg:var(--color-white);--lb-muted:var(--color-gray-3);\n/* --lb-field must NOT equal --lb-surface: a control needs its own ground or it\n reads as text on the card (was 1.00:1). */\n--lb-field:#2c2c2c;\n--lb-chip-bg:#3b3b3b;\n--lb-control-border:var(--color-gray-6);\n--lb-shadow:0 0.5rem 1.5rem oklch(0 0 0/.5),0 0.125rem 0.5rem oklch(0 0 0/.35);\ncolor-scheme:dark;\n--color-blue-background:oklch(0.30 0.055 251);--color-blue-foreground:oklch(0.80 0.11 251);\n--color-green-background:oklch(0.30 0.055 141);--color-green-foreground:oklch(0.80 0.13 141);\n--color-red-background:oklch(0.30 0.060 26);--color-red-foreground:oklch(0.80 0.11 26);\n--color-gold-background:oklch(0.30 0.055 91);--color-gold-foreground:oklch(0.82 0.13 91);\n--color-cherry-background:oklch(0.30 0.055 15);--color-cherry-foreground:oklch(0.82 0.10 15);\n}}\n.lb-card{display:grid;gap:var(--lb-gap);padding:0.875rem;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\nborder-radius:var(--lb-radius);corner-shape:squircle;color:var(--lb-fg);\nfont-family:var(--lb-font);\nbox-shadow:var(--lb-shadow)}\n/* The head must be able to wrap. Both children default to flex:0 1 auto, so\n without this they shrink together and a long company name is crushed to three\n lines while the chips keep full width. flex-basis:11rem is a content breakpoint:\n below ~176px for the title, the chips drop to their own line instead. */\n.lb-card-head{display:flex;flex-wrap:wrap;justify-content:space-between;\nalign-items:baseline;gap:var(--lb-gap)}\n/* An email or URL has no break opportunity \\u2014 browsers do not break at "@" or\n ".", so without this a company address escapes the card and the chat host\n clips it with no way to recover the tail. */\n.lb-title{font-size:0.875rem;font-weight:600;line-height:1.3;color:var(--lb-fg);\noverflow-wrap:break-word;min-width:0;flex:1 1 11rem}\n.lb-sub{font-size:0.8125rem;line-height:1.45;color:var(--lb-muted);\noverflow-wrap:break-word;max-width:68ch}\n/* An empty fact line is a 30px void that reads as a rendering bug. */\n.lb-sub:empty{display:none}\n/* Every card child shared one 12px gap, so nothing grouped: the switchboard\n number, the contact and the fit judgement read as one paragraph. 4px inside\n the facts vs 12px to the why line is a 3x ratio \\u2014 grouping by space, with no\n rule or border. */\n.lb-facts{display:grid;gap:0.25rem}\n/* The contract\'s canonical skeleton already emits data-why; the sheet just did\n not honour it, so consumers hand-patched an inline font-style. */\n.lb-sub[data-why]{font-style:italic;color:var(--lb-fg)}\n/* Icon column: the glyph gets its own cell so a wrapping line hangs on one\n shared text edge instead of running back under the emoji. */\n.lb-fact{display:grid;grid-template-columns:1.15rem 1fr;gap:0.35rem;align-items:baseline}\n.lb-row{display:flex;align-items:center;gap:var(--lb-gap);flex-wrap:wrap}\n/* row-gap matters once the row wraps: without it the wrapped line sits flush\n against the one above. */\n.lb-row{row-gap:0.5rem}\n.lb-stack{display:grid;gap:var(--lb-gap)}\n.lb-select,.lb-input{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;color:var(--lb-fg);\nbackground-color:var(--lb-field);border:1px solid var(--lb-control-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.55rem;min-height:2.125rem}\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--lb-fg);background-color:var(--lb-field);border:1px solid var(--lb-control-border);\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;padding:0.4rem 0.85rem;min-height:2.125rem;\ncursor:pointer;transition:background-color .15s,border-color .15s,color .15s}\n.lb-btn:hover:not([disabled]){border-color:var(--color-gray-7)}\n/* A SUBMIT is not a toggle. Like/Dislike are independent switches; "Set status"\n commits the value sitting in the select beside it. Rendered identically, the\n row reads as five peers and the rep cannot see which control ends the task.\n The accent border + tinted ground marks it as the commit without making it a\n loud filled button \\u2014 the state styles above still win when they apply. */\n/* INVERTED, not recoloured. A normal button is --lb-fg on --lb-field; the\n submit swaps them. That introduces no new hue, and it themes for free: light\n gives near-black on white, dark gives white on near-black \\u2014 the polarity flip\n reads as "this one commits" in both, without competing with the semantic\n state colours (success green / error red) that still override it. */\n.lb-btn-submit{background-color:var(--lb-fg);color:var(--lb-field);\nborder-color:var(--lb-fg)}\n.lb-btn-submit:hover:not([disabled]){\nbackground-color:color-mix(in srgb,var(--lb-fg) 85%,var(--lb-field));\nborder-color:var(--lb-fg)}\n/* Toggles that act on the same axis belong in one visual unit, so the row reads\n as "taste | status | inspect" rather than five equal buttons. The gap inside\n a group is half the gap between groups \\u2014 the 2x rule, applied horizontally. */\n.lb-group{display:inline-flex;align-items:center;gap:0.375rem;flex-wrap:wrap}\n/* Square icon button. Only for actions whose glyph is unambiguous on its own \\u2014\n thumbs up/down qualify, "Set status" does not. It MUST carry an aria-label\n and a title: with no text the icon is the entire affordance, so an unlabelled\n one is unreadable to a screen reader and to anyone who does not know the\n glyph. Same 2.125rem box as a text button, so it keeps the row\'s baseline. */\n.lb-btn-icon{padding:0;width:2.125rem;justify-content:center;display:inline-flex;\nalign-items:center}\n.lb-btn-icon svg{width:1.05rem;height:1.05rem;flex-shrink:0}\n/* Pressed state. The taste chip carries the durable fact; this is the control\n reflecting it, and it must not rely on colour alone \\u2014 aria-pressed is the\n accessible half and the fill is the visible half. */\n/* A heart that stays hollow does not read as "liked" \\u2014 fill it from the same\n currentColor the stroke uses, so one rule covers both themes. */\n.lb-btn-icon[aria-pressed=true] svg{fill:currentColor}\n.lb-btn-icon[aria-pressed=true]{border-color:var(--color-red-like);\ncolor:var(--color-red-like);\nbackground-color:color-mix(in srgb,var(--color-red-like) 10%,var(--lb-field))}\n.lb-btn-icon[data-taste=disliked][aria-pressed=true]{border-color:var(--lb-muted);\ncolor:var(--lb-muted);\nbackground-color:color-mix(in srgb,var(--lb-muted) 10%,var(--lb-field))}\n/* A glyph-only control has no text to widen its hit area, so extend the target\n past the 34px box without inflating the row. */\n.lb-btn-icon{position:relative}\n.lb-btn-icon::before{content:"";position:absolute;inset:-5px}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible,\n.lb-link:focus-visible{\noutline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n/* Forced colours replaces every background and custom outline with a system\n colour, collapsing the state styling above into one flat control. */\n@media(forced-colors:active){\n.lb-btn,.lb-select,.lb-input,.lb-chip{border:1px solid CanvasText}\n.lb-btn:focus-visible,.lb-select:focus-visible,.lb-input:focus-visible,\n.lb-link:focus-visible,.lb-link-out:focus-visible{outline:2px solid Highlight}\n.lb-link-out{opacity:1}}\n/* Each state carries a glyph as well as a hue: success and error were two\n pills with identical labels, indistinguishable to a red-green colour-blind\n reader and identical under forced colours. */\n.lb-btn[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-btn[data-lb-state=loading]::after{content:"\\u2026";margin-inline-start:.35rem}\n/* An icon button has no room for a trailing glyph \\u2014 it would push the icon\n off-centre. Its states read through border and fill, which the forced-colors\n block below keeps distinguishable. */\n.lb-btn-icon::after{content:none!important}\n.lb-btn[data-lb-state=success]::after{content:"\\u2713";margin-inline-start:.35rem}\n.lb-btn[data-lb-state=error]::after{content:"\\u2715";margin-inline-start:.35rem}\n.lb-btn[data-lb-state=unavailable]::after{content:"\\u26A0";margin-inline-start:.35rem}\n/* With save-on-change there is no button to reflect the write, so the select\n itself must. Only error was styled before; loading and success were invisible\n on a select, leaving the rep unsure whether the pick had landed. */\n.lb-select[data-lb-state=loading]{opacity:.55;cursor:progress}\n.lb-select[data-lb-state=success]{border-color:var(--color-green-foreground)}\n.lb-msg[data-tone=error]::before{content:"\\u2715 "}\n.lb-msg[data-tone=ok]::before{content:"\\u2713 "}\n.lb-btn[data-lb-state=success]{background-color:var(--color-green-background);\nborder-color:var(--color-green-foreground);color:var(--color-green-foreground)}\n.lb-btn[data-lb-state=error],.lb-select[data-lb-state=error]{\nbackground-color:var(--color-red-background);border-color:var(--color-red-foreground);\ncolor:var(--color-red-foreground)}\n.lb-btn[data-lb-state=unavailable],.lb-btn[disabled]{opacity:.5;cursor:not-allowed}\n.lb-msg{font-size:0.8125rem;line-height:1.45;color:var(--lb-muted)}\n/* Screen-reader-only text: control labels, unit suffixes, "opens in a new tab". */\n.lb-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;\noverflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}\n.lb-msg[data-tone=error]{color:var(--color-red-foreground)}\n.lb-msg[data-tone=ok]{color:var(--color-green-foreground)}\n.lb-chip{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap;\nfont-size:0.75rem;font-weight:600;line-height:1rem;padding:0.125rem 0.5rem;\nborder-radius:var(--lb-radius-sm);corner-shape:squircle;\nbackground-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chip[data-status=WANTED]{background-color:var(--color-blue-background);color:var(--color-blue-foreground)}\n.lb-chip[data-status=WON]{background-color:var(--color-green-background);color:var(--color-green-foreground)}\n.lb-chip[data-status=LOST]{background-color:var(--color-red-background);color:var(--color-red-foreground)}\n.lb-chip[data-status=UNWANTED]{background-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chip[data-taste=liked]{background-color:var(--color-cherry-background);color:var(--color-red-like)}\n.lb-chip[data-taste=disliked]{background-color:var(--lb-chip-bg);color:var(--lb-muted)}\n.lb-chips{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}\n.lb-chip[hidden]{display:none}\n.lb-table{width:100%;border-collapse:collapse;font-family:var(--lb-font);color:var(--lb-fg)}\n.lb-table th,.lb-table td{text-align:start;padding:0.5rem 0.4rem;\nborder-bottom:1px solid var(--lb-border);vertical-align:middle;font-size:0.8125rem}\n.lb-table th{font-size:0.75rem;font-weight:600;color:var(--lb-muted);\ntext-transform:uppercase;letter-spacing:.04em}\n.lb-link{color:var(--color-blue-foreground);text-decoration:none}\n.lb-link:hover{text-decoration:underline}\n/* Quiet text link out of the artifact. Button-height so it shares the row\'s\n baseline; understated so it never competes with the actions beside it. The\n arrow is a bare diagonal stroke \\u2014 an escape-hatch marker, not an icon that\n asks to be read. */\n.lb-link-out{display:inline-flex;align-items:center;gap:.3rem;\nfont-size:0.75rem;line-height:1rem;min-height:2.125rem;\ncolor:var(--lb-fg);text-decoration:none;opacity:.65;transition:opacity .15s}\n.lb-link-out:hover{opacity:1;text-decoration:underline}\n.lb-link-out:focus-visible{outline:2px solid var(--color-blue-foreground);outline-offset:1px;\nborder-radius:var(--lb-radius-sm)}\n.lb-link-out svg{width:.85em;height:.85em;flex-shrink:0}\n/* Pushes whatever follows it to the right edge of an .lb-row, so a trailing\n link sits on the SAME baseline as the row\'s buttons instead of on its own\n line below them. */\n/* A spacer only aligns items on its OWN flex line. .lb-row wraps, so once the\n link falls to a second line the spacer stays behind and the link renders\n left-aligned \\u2014 the opposite of what the contract promises. The auto margin\n re-anchors it to the trailing edge of whichever line it lands on, and is a\n no-op while the row is unwrapped. */\n.lb-spacer{flex:1 1 auto;min-width:0}\n.lb-row>.lb-link-out{margin-inline-start:auto}\n.lb-spinner{display:inline-block;width:.7em;height:.7em;border:2px solid var(--lb-border);\nborder-top-color:var(--color-blue-foreground);border-radius:50%;animation:lb-spin .8s linear infinite}\n@keyframes lb-spin{to{transform:rotate(1turn)}}\n/* animation:none left a static ring \\u2014 under reduced motion "sending" and\n "cannot click" looked identical. An opacity pulse is the sanctioned\n substitute. The 150ms colour fade is fine and stays. */\n@media(prefers-reduced-motion:reduce){.lb-spinner{animation:lb-pulse 1.4s ease-in-out infinite}}\n@keyframes lb-pulse{50%{opacity:.35}}\n`,v="lb-styles";var L="0.5.0",u=class extends Error{constructor(r,o={}){super(r);a(this,"code");a(this,"raw");this.name="LbError",this.code=o.code,this.raw=o.raw}},k=null,h=3e4;function C(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(t,r)=>e.callMcpTool(t,r):null}function T(e){if(e&&typeof e=="object"&&"content"in e){let t=e.content;if(Array.isArray(t)&&t[0]&&typeof t[0].text=="string")return t[0].text}return null}function _(e){if(!e||typeof e!="object")return e;let t=e;if(t.isError)throw new u(T(e)??"tool call failed",{raw:e});if("structuredContent"in t&&t.structuredContent!=null)return t.structuredContent;let r=T(e);if(r!=null)try{return JSON.parse(r)}catch{return r}return e}function I(e){return e instanceof Error?e.message:String(e)}function p(e){let t=e instanceof u?e.code:void 0;return{message:I(e),unavailable:t==="unavailable",code:t}}function O(e={}){k=e.call??null,h=e.timeoutMs??3e4}function R(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(v);if(e)return e;let t=document.createElement("style");return t.id=v,t.textContent=S,document.head.appendChild(t),t}async function x(e,t){if(!h||h<=0)return e;let r,o=new Promise((n,i)=>{r=setTimeout(()=>i(new u(`"${t}" timed out after ${h}ms`,{code:"timeout"})),h)});try{return await Promise.race([e,o])}finally{r&&clearTimeout(r)}}async function l(e,t={}){if(k)return _(await x(Promise.resolve(k(e,t)),e));let r=C();if(!r)throw new u("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});return _(await x(Promise.resolve(r(e,t)),e))}var b=class{constructor(){a(this,"subs",new Set)}subscribe(t){return this.subs.add(t),t(this),()=>this.subs.delete(t)}emit(){for(let t of this.subs)t(this)}};function N(e){return Array.isArray(e)?e.map(t=>t&&typeof t=="object"?t:{value:t,label:String(t)}):[]}var g=class extends b{constructor(r={}){super();a(this,"kind");a(this,"value");a(this,"options",[]);a(this,"loading",!1);a(this,"error",null);a(this,"ready",!1);a(this,"cfg");a(this,"depUnsubs",[]);a(this,"seq",0);this.cfg=r,this.kind=r.kind,this.value=r.value??"";for(let o of r.dependsOn??[]){let n=o.value;this.depUnsubs.push(o.subscribe(()=>{o.value!==n&&(n=o.value,this.cfg.load&&this.load())}))}r.load&&(r.autoLoad??!0)&&this.load()}async load(){if(!this.cfg.load)return;let r=++this.seq;this.loading=!0,this.error=null,this.emit();try{let o=await this.cfg.load();if(r!==this.seq)return;this.options=this.cfg.options?this.cfg.options(o):N(o),this.ready=!0;let n=this.value==null?"":String(this.value);this.options.length&&(n===""||!this.options.some(i=>String(i.value)===n))&&(this.value=this.options[0].value)}catch(o){if(r!==this.seq)return;this.options=[],this.error=p(o)}finally{r===this.seq&&(this.loading=!1,this.emit())}}setValue(r){this.value=r;let o=this.validate();this.error=o?{message:o,unavailable:!1}:null,this.emit()}validate(){return this.cfg.validate?this.cfg.validate(this.value):null}get valid(){return this.validate()==null}reset(){this.value=this.cfg.value??"",this.error=null,this.emit()}dispose(){for(let r of this.depUnsubs)r();this.depUnsubs=[]}};function P(e){if(!e||typeof e!="object")return null;let t=e;if(t.error!==!0)return null;let r=typeof t.message=="string"&&t.message?t.message:"tool call failed",o=typeof t.hint=="string"&&t.hint?` \\u2014 ${t.hint}`:"";return`${r}${o}`}var s=class extends b{constructor(r){super();a(this,"loading",!1);a(this,"error",null);a(this,"lastResult",null);a(this,"cfg");this.cfg=r}async run(){if(this.loading)return;for(let n of this.cfg.fields??[]){let i=n.validate();if(i!=null){this.error={message:i,unavailable:!1},this.emit();return}}if(this.cfg.confirm&&typeof globalThis.confirm=="function"&&!globalThis.confirm(this.cfg.confirm))return;this.loading=!0,this.error=null,this.emit();let r;try{let n=typeof this.cfg.args=="function"?this.cfg.args():this.cfg.args??{};r=await l(this.cfg.tool,n)}catch(n){this.error=p(n),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let o=P(r)??this.cfg.checkResult?.(r)??null;if(o!=null){this.error={message:o,unavailable:!1},this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}return this.lastResult=r,this.loading=!1,this.emit(),this.cfg.onSuccess?.(r),r}reset(){this.error=null,this.lastResult=null,this.emit()}},d=class extends b{constructor(r){super();a(this,"data",null);a(this,"loading",!1);a(this,"refreshing",!1);a(this,"error",null);a(this,"done",!1);a(this,"cfg");a(this,"timer",null);a(this,"seq",0);this.cfg=r,(r.autoLoad??!0)&&this.load()}async load(){this.clearTimer();let r=++this.seq;this.data==null?this.loading=!0:this.refreshing=!0,this.error=null,this.emit();try{let n=await this.cfg.load();if(r!==this.seq)return;this.data=n,this.done=this.cfg.until?this.cfg.until(n):!0,this.cfg.pollEvery&&!this.done&&(this.timer=setTimeout(()=>void this.load(),this.cfg.pollEvery))}catch(n){if(r!==this.seq)return;this.error=p(n)}finally{r===this.seq&&(this.loading=!1,this.refreshing=!1,this.emit())}}refresh(){return this.load()}stop(){this.clearTimer()}clearTimer(){this.timer&&(clearTimeout(this.timer),this.timer=null)}},f=class extends b{constructor(r){super();a(this,"items",[]);a(this,"page",0);a(this,"pageSize");a(this,"total",0);a(this,"loading",!1);a(this,"error",null);a(this,"cfg");a(this,"seq",0);this.cfg=r,this.pageSize=r.pageSize??20,(r.autoLoad??!0)&&this.loadPage(0)}async loadPage(r){let o=++this.seq;this.loading=!0,this.error=null,this.emit();try{let n=await this.cfg.load({page:r,pageSize:this.pageSize});if(o!==this.seq)return;this.items=n.items??[],this.total=n.total??this.items.length,this.page=r}catch(n){if(o!==this.seq)return;this.error=p(n)}finally{o===this.seq&&(this.loading=!1,this.emit())}}next(){return this.loadPage(this.page+1)}prev(){return this.loadPage(Math.max(0,this.page-1))}get hasMore(){return(this.page+1)*this.pageSize<this.total}};function M(e,t){let r=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":"ready";e.setAttribute("data-lb-state",r),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")}function D(e,t){let r=()=>t.setValue(e.value);e.addEventListener("change",r);let o=t.subscribe(()=>{M(e,t),e.disabled=t.loading,e.innerHTML="";for(let n of t.options){let i=document.createElement("option");i.value=String(n.value),i.textContent=n.label,e.appendChild(i)}e.value=t.value==null?"":String(t.value)});return()=>{e.removeEventListener("change",r),o()}}function z(e,t){let r=e.type==="checkbox",o=e.tagName==="SELECT"?"change":"input",n=()=>t.setValue(r?e.checked:e.value);e.addEventListener(o,n);let i=t.subscribe(()=>{if(r)e.checked=!!t.value;else{let c=t.value==null?"":String(t.value);e.value!==c&&(e.value=c)}e.setAttribute("data-lb-state",t.error?"error":"ready"),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error")});return()=>{e.removeEventListener(o,n),i()}}function q(e,t){let r=i=>{i.preventDefault(),t.run()};e.addEventListener("click",r);let o,n=t.subscribe(()=>{let i=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":t.lastResult!=null?"success":"ready";e.setAttribute("data-lb-state",i),"disabled"in e&&(e.disabled=t.loading),e.setAttribute("aria-busy",String(t.loading)),t.error?e.setAttribute("data-lb-error",t.error.message):e.removeAttribute("data-lb-error"),o&&clearTimeout(o),i==="success"&&(o=setTimeout(()=>{!t.loading&&!t.error&&e.setAttribute("data-lb-state","ready")},1600))});return()=>{e.removeEventListener("click",r),n(),o&&clearTimeout(o)}}var F=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],y=[{value:"",label:"Default ranking"},{value:"SCORE:DESC",label:"Score \\u2193"},{value:"SCORE:ASC",label:"Score \\u2191"},{value:"NAME:ASC",label:"Name A\\u2192Z"},{value:"NAME:DESC",label:"Name Z\\u2192A"},{value:"SIZE:DESC",label:"Size \\u2193"},{value:"SIZE:ASC",label:"Size \\u2191"},{value:"SECTOR:ASC",label:"Sector A\\u2192Z"},{value:"STATUS:ASC",label:"Status A\\u2192Z"},{value:"CONTACT_COUNT:DESC",label:"Contacts \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:DESC",label:"Last action \\u2193"},{value:"LAST_PROSPECTING_ACTION_AT:ASC",label:"Last action \\u2191"},{value:"EPILOGUE_STATUS_SET_AT:DESC",label:"Outcome set \\u2193"},{value:"LIKED:DESC",label:"Liked first"},{value:"DISLIKED:DESC",label:"Disliked first"}];function U(e){let t=String(e??"").trim().toUpperCase(),r=y.some(o=>o.value===t);return new g({kind:"select",value:r?t:"",load:async()=>y.slice()})}var m=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],H={value:"",label:"Pick a status"};function j(e){let t=String(e??"").trim().toUpperCase(),r=m.some(o=>o.value===t);return new g({kind:"select",value:r?t:"",validate:o=>String(o??"")===""?"Pick a status":null,load:async()=>r?m.slice():[H,...m]})}function W(e){let t=()=>{let r=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(r)?r:e.leadId?[e.leadId]:[]};return new s({tool:"leadbay_set_lead_status",fields:e.date?[e.status,e.date]:[e.status],confirm:e.confirm,args:()=>({lead_ids:t(),status:e.status.value,...e.date&&e.date.value?{status_date:e.date.value}:{},...e.ask?{_triggered_by:e.ask}:{}}),checkResult:r=>{let o=r?.failed;if(!Array.isArray(o)||o.length===0)return null;let n=t().length,i=o[0]?.message??"write rejected";return o.length===n?`Status not applied: ${i}`:`${o.length} of ${n} leads failed: ${i}`}})}function V(e){return new g({kind:"select",load:()=>l("leadbay_list_campaigns",{_triggered_by:e}),options:t=>(t?.campaigns??[]).map(o=>{let n=o?.campaign??o;return n?.id?{value:n.id,label:n.name??n.ai_generated_name??String(n.id)}:null}).filter(o=>o!=null)})}function $(e){return new s({tool:"leadbay_report_outreach",fields:e.note?[e.note]:[],args:()=>({lead_id:e.leadId,...e.status?{epilogue_status:e.status.value}:{},note:e.note?e.note.value:"",verification:{source:"user_confirmed",ref:e.ref??"logged from artifact"},_triggered_by:e.ask})})}function G(e){return new s({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function B(e){return new s({tool:"leadbay_like_lead",args:{lead_id:e}})}function Z(e){return new s({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function Y(e,t){return new d({autoLoad:!1,load:()=>l("leadbay_account_history",{leadId:e,_triggered_by:t})})}function K(e,t){return new d({autoLoad:!1,load:()=>l("leadbay_research_lead_by_id",{leadId:e,_triggered_by:t})})}function J(e){let t=null;return new d({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:r=>!!r?.all_done,load:async()=>{if(!t){let r=await l("leadbay_enrich_titles",{...e.leadIds?{leadIds:e.leadIds}:{},titles:e.titles,...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},...e.confirm!==void 0?{confirm:e.confirm}:{},_triggered_by:e.ask}),o=Array.isArray(r?.lead_ids)?r.lead_ids:[],n=r?.notification_id??null;if(t=n||o.length>0?{notification_id:n,lead_ids:o}:null,!t)return{...r,all_done:!0,no_job:!0}}return l("leadbay_bulk_enrich_status",{...t.notification_id?{notification_id:t.notification_id}:{},...t.lead_ids.length>0?{lead_ids:t.lead_ids}:{},...e.titles?{titles:e.titles}:{},...e.email!==void 0?{email:e.email}:{},...e.phone!==void 0?{phone:e.phone}:{},_triggered_by:e.ask})}})}function Q(e){let t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new f({pageSize:e.pageSize??20,load:async({page:r,pageSize:o})=>{let n=await l("leadbay_pull_leads",{page:r,count:o,...e.lensId?{lensId:e.lensId}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),i=n.leads??[];return{items:i,total:n.pagination?.total??i.length}}})}function X(e){let t=e.source??"followups",r=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new f({pageSize:e.pageSize??20,load:async({page:o,pageSize:n})=>{let c=t==="campaign"?await l("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:o,count:n,_triggered_by:e.ask}):await l("leadbay_pull_followups",{page:o,count:n,...e.city?{city:e.city}:{},...r()?{order:r()}:{},_triggered_by:e.ask}),w=c.leads??c.items??[];return{items:w,total:c.total_leads??c.pagination?.total??w.length}}})}function ee(e){return new d({load:()=>l("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var te={VERSION:L,configure:O,styles:R,call:l,field:e=>new g(e),action:e=>new s(e),resource:e=>new d(e),list:e=>new f(e),bindSelect:D,bindValue:z,bindAction:q,campaigns:V,outreach:$,note:G,like:B,dislike:Z,leadStatus:j,setStatus:W,sortOrder:U,leadHistory:Y,leadProfile:K,enrichment:J,callList:X,leadList:Q,teamActivity:ee,EPILOGUE_STATUSES:F,LEAD_STATUSES:m,SORT_ORDERS:y};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=te);})();';
|
|
26291
|
+
var ARTIFACT_USAGE_GUIDE = '# Leadbay Artifact Kit \u2014 headless domain components\n\nYou are building a single-file HTML **artifact** the user runs inside cowork. This\nkit gives you **headless view-models** that own a control\'s whole data lifecycle \u2014\nload/populate from a Leadbay call, hold value/state, poll, validate, and\nencapsulate the API call + business rules. **You own 100% of markup/layout/style.**\nThe library renders nothing. Inline the runtime once as a `<script>`; it exposes\none global `window.LeadbayArtifacts` (call it `lb`). Vanilla, no React, no build.\n\nPass every tool you use as the artifact\'s `mcp_tools` so the host permits it.\n\n## Two layers\n\n**Primitives** (generic):\n- `lb.field({ load, options, value, validate, dependsOn })` \u2014 a value + optionally\n API-populated options. `.value/.setValue/.options/.loading/.error/.valid/.subscribe`.\n- `lb.action({ tool, args, fields, confirm, onSuccess, onError })` \u2014 a write/submit.\n `.run()/.loading/.error/.lastResult/.subscribe`.\n- `lb.resource({ load, pollEvery?, until?, autoLoad? })` \u2014 one read that may change:\n load-on-click or poll-until-`until`. `.data/.loading/.refreshing/.error/.done/.load()/.refresh()/.stop()/.subscribe`.\n- `lb.list({ load, pageSize })` \u2014 paginated rows. `.items/.page/.total/.loading/.loadPage(n)/.next()/.prev()/.hasMore/.subscribe`.\n\n`.error` is `{ message, unavailable } | null`. `subscribe(cb)` fires immediately\nthen on every change \u2014 render your own DOM from it.\n\n**Domain components** (pre-wired \u2014 bake in the tool name, arg shape, and footguns):\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.campaigns(ask)` | field | a campaign `<select>`, options from `leadbay_list_campaigns` |\n| `lb.outreach({leadId, ask, status?, note?})` | action | log a call \u2192 `report_outreach` (verification + `_triggered_by` baked in) |\n| `lb.note({leadId, note})` | action | add a note \u2192 `add_note` |\n| `lb.like(leadId)` / `lb.dislike(leadId)` | action | taste signal |\n| `lb.leadStatus(current?)` | field | a status `<select>` (Wanted/Won/Lost/Unwanted) |\n| `lb.setStatus({leadId or leadIds, status, date?, ask})` | action | write the org CRM status \u2192 `set_lead_status` |\n| `lb.leadHistory(leadId, ask)` | resource (lazy) | notes + activities + engagement \u2192 `account_history` |\n| `lb.leadProfile(leadId, ask)` | resource (lazy) | full lead profile \u2192 `research_lead_by_id` |\n| `lb.sortOrder(current?)` | field | a sort `<select>` mirroring the app\'s TableSort |\n| `lb.leadList({lensId?, order?, ask})` | list | a sortable Discover batch \u2192 `pull_leads` |\n| `lb.callList({source:\'followups\'\\|\'campaign\', campaignId?, city?, ask})` | list | a cold-call list (Monitor or a campaign) |\n| `lb.enrichment({leadIds, titles, ask, pollEvery?})` | resource (polling) | launch + watch contact enrichment |\n| `lb.teamActivity({weeks, ask})` | resource | manager leaderboard + activity trend \u2192 `leadbay_team_activity` |\n\n`lb.EPILOGUE_STATUSES` = the 4 disposition values\n(`STILL_CHASING`, `COULD_NOT_REACH_STILL_TRYING`, `INTEREST_VALIDATED_OR_MEETING_PLANED`, `NOT_INTERESTED_LOST`).\n`lb.LEAD_STATUSES` = the 4 org CRM statuses as `{value,label}` (`WANTED`, `WON`, `LOST`, `UNWANTED`).\n`lb.SORT_ORDERS` = the sort options as `{value,label}`; values are the backend `FIELD:ASC|DESC` enum.\n\n**Sorting is a SERVER concern.** `lb.leadList` and `lb.callList` take an `order`\n(a `lb.sortOrder()` field or a literal) and send it upstream; the backend sorts\nthe whole lens / Monitor and returns the requested page of that. Never re-sort\nrows in the browser \u2014 you would be reordering one page of a larger set, showing\nleads that do not belong at that position. The empty value means "no order\nparam", i.e. the tab\'s own ranking, which is the right default. Changing the\nsort should reset to page 0. Campaign call sheets cannot sort:\n`leadbay_campaign_call_sheet` has no `order` param, and `lb.callList` drops it\nfor that source rather than sending something the tool would reject.\n\n**Two different systems.** Epilogue = how one outreach attempt went (drives\nfollow-up ranking). Lead status = the commercial outcome, org-wide \u2014 the same\nfield the website\'s status selector writes. A won deal is a LEAD STATUS;\n"she didn\'t pick up" is an EPILOGUE. Setting one never sets the other, so when\nthe user reports both in one breath, fire both actions.\n\n**Binding sugar** (optional; binds a view-model to YOUR native element, no style):\n`lb.bindSelect(selectEl, field)` (populates options + value), `lb.bindValue(inputEl, field)`,\n`lb.bindAction(buttonEl, action)`. They set `data-lb-state`\n(`ready|loading|error|success|unavailable`) + `data-lb-error` on your element as\nstyling hooks. For lists/resources, use `.subscribe()` and render yourself.\n\n`ask` is the user\'s request this artifact serves \u2014 it becomes `_triggered_by`.\n\n## The skin (optional) \u2014 `lb.styles()`\n\nCall it once and you get a small `lb-*` stylesheet, so every artifact you build\nshares one visual language instead of re-inventing padding and colours. It is\n**opt-in**: skip it and you get exactly the unstyled HTML you wrote. It injects\nno markup and never touches your `class` attributes.\n\n```js\nlb.styles(); // idempotent \u2014 safe to call per row\n```\n\n| Class | For |\n|---|---|\n| `lb-card` / `lb-card-head` / `lb-title` / `lb-sub` | a lead card + its header |\n| `lb-row` / `lb-stack` / `lb-spacer` | control row / vertical spacing / flex filler that right-aligns what follows |\n| `lb-link-out` | quiet external link (icon inherits currentColor) \u2014 "Open in Leadbay" |\n| `lb-select` / `lb-input` / `lb-btn` | form controls (state-aware, see below) |\n| `lb-msg` (`data-tone="error\\|ok"`) | inline feedback |\n| `lb-chip` (`data-status="WON\\|LOST"`) | a status pill |\n| `lb-table` | leads table |\n| `lb-spinner` | inline busy indicator \u2014 decorative, mark it `aria-hidden` |\n| `lb-vh` | visually-hidden text \u2014 labels heard but not seen |\n\nControls react to the `data-lb-state` the bind helpers already set \u2014 a bound\n`lb-btn` dims while loading, goes green on success, red on error, all with no\nextra CSS from you.\n\nThe palette is the **product design system**, ported from\n`frontend/packages/style/color.css` \u2014 same `--color-gray-1\u20269` ramp, same\nsemantic `--color-{green,red,blue,gold}-{background,foreground}` pairs, same\n`1.5rem` / `0.625rem` radii (concentric: outer = inner + padding) and\n`corner-shape: squircle`, matching the app\'s components.\nAn artifact therefore looks like Leadbay, not like a generic page.\n\nUse the tokens rather than hardcoded colours \u2014 the same rule the style package\nenforces. Re-theme by overriding them; don\'t fight specificity:\n\n```css\n:root { --lb-surface: var(--color-gray-2); --lb-radius: 0.5rem; }\n```\n\nDark mode works two ways: `data-theme="dark"` on `<html>` (the frontend\'s own\nhook) **and** `prefers-color-scheme`, because an artifact renders inside a host\nwhose theme attribute it cannot set. Never hardcode a light background over the\nskin.\n\nThe product face is `Nikkei Maru`; the stack names it first and falls back to\nthe system UI font. Do **not** add an `@font-face` \u2014 artifacts are inline-only\nand a remote font URL will silently fail.\n\n## What every lead card MUST carry\n\nA card is the artifact form of the `pull_leads` table, and it inherits that\ntable\'s rules. A card with a name and a button is not enough: the rep cannot\ntell *why* this lead is on screen. Five lines, in this order.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title"></span> <!-- 1. company -->\n <span class="lb-chips"> <!-- 2. state -->\n <span class="lb-chip" data-taste hidden></span>\n <span class="lb-chip" data-status hidden></span>\n </span>\n </div>\n <div class="lb-facts"> <!-- one group, tight 4px gap -->\n <div class="lb-sub"></div> <!-- 3. firmographics -->\n <div class="lb-sub" data-who></div> <!-- 4. the person -->\n <div class="lb-sub" data-how></div> <!-- 5. company channels -->\n </div>\n <div class="lb-sub" data-why></div> <!-- 6. why it fits -->\n <div class="lb-row"><!-- actions --></div>\n <div class="lb-msg" role="status" aria-live="polite"></div>\n</div>\n```\n\n1. **Company** \u2014 `name`, linked to `website` (prefix `https://` on a bare host).\n Never render the numeric `score`; use the `\u25B0\u2756\u25B1` bar if you want the signal.\n\n Keep `.lb-msg` OUT of `.lb-row`. The result of a write \u2014 "Could not reach the\n host" \u2014 is the most important thing on the card at that moment; parked between\n two buttons it reads as a control, and as a wide flex item it forces the\n trailing link onto a line of its own. Give it its own row after the actions.\n\n Also give every card an **Open in Leadbay** link to the lead\'s panel in the\n product. Put it at the **trailing end of the card\'s last action row** \u2014\n same row as the buttons, pushed right by an `lb-spacer`, not on a line of\n its own. Style it `lb-link-out`: quiet text plus a plain arrow-up-right,\n never a filled button. It is an escape hatch, not a call to action.\n\n Group the controls by what they act on. A flat row of five buttons reads as\n five peers; the rep cannot see that "Set status" commits the select beside it\n while Like/Dislike are independent toggles. Wrap each axis in an `lb-group`\n and mark the commit with `lb-btn-submit`.\n\n Taste is the one pair worth reducing to icons: thumbs up/down are unambiguous,\n they repeat on every card, and dropping the words buys the width a narrow chat\n host needs. Use `lb-btn-icon` \u2014 and note the three attributes it REQUIRES,\n because with no text the glyph is the whole affordance:\n\n - `aria-label` naming the lead ("Like Acme Corp"), since the control repeats\n N times down the list;\n - `title` so a sighted user who does not know the glyph gets a tooltip;\n - `aria-pressed` reflecting the current taste \u2014 a toggle must say whether it\n is on, and `[aria-pressed=true]` is what the skin styles.\n\n Do **not** reduce "Set status" to an icon: no glyph says "commit the value in\n the select beside me". Icons work for a fixed, well-known action; they fail\n for one whose meaning comes from a neighbouring control.\n\n ```html\n <div class="lb-row">\n <span class="lb-group"> <!-- taste: two toggles -->\n <button class="lb-btn lb-btn-icon" aria-label="Like Acme Corp"\n title="Like" aria-pressed="false">\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>\n </svg>\n </button>\n <button class="lb-btn lb-btn-icon" data-taste="disliked"\n aria-label="Dislike Acme Corp" title="Dislike" aria-pressed="false">\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <path d="M17 14V2"/>\n <path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/>\n </svg>\n </button>\n </span>\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n <span class="lb-spacer"></span> <!-- pushes the link to the end -->\n <a class="lb-link-out" data-k="open" target="_blank" rel="noopener">\n Open in Leadbay\n <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"\n stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">\n <line x1="7" y1="17" x2="17" y2="7"/><polyline points="7 7 17 7 17 17"/>\n </svg>\n </a>\n </div>\n ```\n\n Keep the arrow a bare diagonal stroke \u2014 the text already says where the link\n goes, so the glyph only has to mark "leaves this page". Mark the `<svg>`\n `aria-hidden="true"`: it is decorative, and the link text is the accessible\n name.\n **Pick the view the lead actually lives in** \u2014 the URL is\n `/app/<view>?lead=<uuid>`, and the three views are `discover`, `monitor`,\n `campaign`. Landing a Monitor lead on Discover drops the rep into a list\n that does not contain it:\n\n ```js\n function leadUrl(lead, campaignId) {\n const id = encodeURIComponent(lead.id);\n // A campaign card carries TWO params \u2014 the campaign selects the list, the\n // lead opens the panel inside it. Campaign wins even when in_monitor is\n // also true, because that is the list the rep is looking at.\n if (campaignId) {\n return `https://leadbay.app/app/campaign?campaign=${encodeURIComponent(campaignId)}&lead=${id}`;\n }\n const view = lead.in_monitor ? "monitor" : "discover";\n return `https://leadbay.app/app/${view}?lead=${id}`;\n }\n openEl.href = leadUrl(lead, campaignId);\n ```\n\n `in_monitor` / `in_discover` are booleans on the `pull_followups` payload \u2014\n every follow-up carries `in_monitor: true`, so a call sheet must link to\n `monitor`. `pull_leads` omits both flags entirely; its leads are the Discover\n batch by definition, so `discover` is the default. A campaign card\n (`lb.callList({source:"campaign", campaignId})`) needs `?campaign=<id>&lead=<id>`\n \u2014 the param names are `CAMPAIGN_QUERY_PARAM` and `LEAD_QUERY_PARAM`, and the\n app\'s own `useLeadPanel` preserves whatever params are already set, so the\n two coexist by design. Omitting `campaign=` opens an empty campaign view.\n\n Inline the glyph as SVG rather than an emoji or `\u2197` \u2014 it inherits\n `currentColor` and scales with the text, so it stays legible in both themes.\n `?lead=<uuid>` is the real deep-link (`LEAD_QUERY_PARAM` in the web app, read\n on load; the panel is an overlay, so the view choice only decides what sits\n behind it). This is the ONE place a card may use `lead.id`: as a link target,\n never as visible text.\n2. **State chips** \u2014 taste (`data-taste`) and CRM status (`data-status`) are\n INDEPENDENT axes; render both, hide the empty one. Never collapse to one chip.\n3. **Firmographics** \u2014 sector of activity first, then city, then size, then the\n contact. `sector_id` is a RAW ID (`"5136"`), not a label: resolve it via\n `leadbay_list_sectors` (1346 rows \u2014 fetch once, cache, never inline the lot)\n or omit it. Never print the raw id.\n\n **Always show whether the lead is reachable \u2014 and never merge the person\n with the company\'s switchboard.** These are two separate lines:\n\n ```js\n // WHO \u2014 recommended_contact. Name, and job_title ONLY when present; on list\n // payloads it is usually null, and inventing one is worse than omitting it.\n const rc = lead.recommended_contact;\n const who = rc ? [rc.first_name, rc.last_name].filter(Boolean).join(" ") : null;\n const whoLine = who ? who + (rc.job_title ? " \xB7 " + rc.job_title : "") : "No contact yet \u2014 enrich to find one";\n\n // HOW \u2014 company-level channels. `phone_numbers` and `email` belong to the\n // COMPANY, not to `recommended_contact`. Rendering "Jean \xB7 \u260E 0123\u2026" claims a\n // direct line that does not exist; it is the switchboard.\n // The API returns the literal STRING "null" for a missing value \u2014 in\n // phone_numbers as well as email (a real lead ships phone_numbers:["null"]).\n // Guard BOTH or the card prints "\u260E null" as if it were a number.\n const real = (v) => (v && v !== "null" ? v : null);\n const phone = real((lead.phone_numbers || [])[0]);\n const email = real(lead.email);\n const howLine = [phone && "\u260E " + phone, email && "\u2709 " + email].filter(Boolean)\n .join(" \xB7 ") || "No phone or email \u2014 enrich to look for them";\n ```\n\n ```html\n <div class="lb-sub">Sector \xB7 City \xB7 Size</div>\n <div class="lb-sub"><span aria-hidden="true">\u{1F464}</span> Jean-Fran\xE7ois Froemer \xB7 G\xE9rant</div>\n <div class="lb-sub"><span class="lb-vh">Company switchboard: </span><span aria-hidden="true">\u{1F3E2} \u260E</span> 01 23 45 67 89 (company line)</div>\n ```\n\n Label the channel line as the **company\'s**, so a rep reading fast cannot\n mistake it for a direct line. A per-contact email or phone exists only after\n enrichment \u2014 `research_lead_by_id` exposes it as `contacts.reachable[]`, and\n `_meta.has_reachable_contact` is the authoritative flag. The list payloads\n carry neither, so a card built from `pull_leads` / `pull_followups` can only\n ever show company channels. Say "enrich to reveal" rather than implying the\n contact is callable.\n\n Two things that look like reachability and are not: a `linkedin_page` alone\n (the rep cannot message a URL without leaving the artifact \u2014 same rule\n `research_lead_by_id` applies), and `contacts_count > 0` (it counts known\n people, not people you can contact; a lead can show 2518 contacts and zero\n channels). `pull_followups` carries `has_phone` as a ready-made boolean;\n `pull_leads` omits it, so derive from `phone_numbers` there.\n4. **Why it fits** \u2014 one sentence, \u226420 words. Walk this chain and stop at the\n first hit:\n\n 1. `short_description`\n 2. `description` (longer; only on `research_lead_by_id` /\n `research_lead_by_name_fuzzy` \u2014 the trim payloads omit it)\n 3. top 2 `tags[].display_name`\n 4. `qualification_summary.best_response_excerpt`, trimmed to one sentence\n 5. `keywords`, first 3, joined with ` \xB7 `\n 6. the resolved sector label \u2014 better than nothing, and if step 3 already\n printed the sector on the firmographics line, skip to step 7\n 7. the literal *"No description yet \u2014 qualify to add one"* ("qualify" is the\n product\'s own verb; "run qualification to generate one" is a nominalisation)\n\n Never leave this line blank: a silent gap reads as a rendering bug, whereas\n the fallback tells the rep the data is missing and what fixes it.\n\n **The two list payloads are complementary, so the chain must span both.**\n `pull_leads` returns `short_description` on every lead but no `sector_id`;\n `pull_followups` returns `sector_id` but no `short_description` at all. A\n card fed by one will fall through to a different step than the same card fed\n by the other \u2014 that is expected, not a bug. Never call\n `research_lead_by_id` per row just to fill this line: it is one request per\n lead. Fetch it lazily when the rep expands a card.\n\n**Never show** on a card: `id`, `sector_id`, `location.pos`, `location.country`\n(unless city and state are both missing), `is_hq`, `*_in_progress`,\n`highlighted_fields`, `custom_fields`, `stale_at`, `deal_insights`,\n`need_attention*`, any count that is 0, any value that is the string `"null"`.\n\n**Minimum actions.** A card that only displays is a table row that costs more \u2014\nif you are not wiring an action, render the markdown table instead. Wire at\nleast one write, and prefer the set the rep actually needs:\n\n| Card is for | Wire |\n|---|---|\n| triage a discovery batch | `lb.like` / `lb.dislike` + `lb.setStatus` |\n| working a call list | `lb.outreach` (gated on a note) + `lb.leadHistory` |\n| pipeline review | `lb.setStatus` + `lb.note` |\n\nAlways render the `.error` branch of every view-model \u2014 a control that cannot\nreach the host must say so, not sit silent.\n\n **Accessibility is the markup\'s job, not the skin\'s.** `data-lb-state` is a\n STYLING hook; it sets no ARIA. A card renders N times in a list, so every\n repeated control needs a name that says *which* lead it acts on:\n\n ```html\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n <button class="lb-btn" aria-label="Like Acme Corp">Like</button>\n <a class="lb-link-out" aria-label="Open Acme Corp in Leadbay" \u2026>\n <span class="lb-msg" role="status" aria-live="polite"></span>\n ```\n\n Without the `role="status"` node the rep hears nothing when a write fails \u2014\n `bindAction` puts the message in a `data-lb-error` attribute that nothing\n renders. Mark the `\u25B0\u2756\u25B1` bar `aria-hidden="true"` and follow it with\n `<span class="lb-vh">Fit: strong</span>`; the glyphs otherwise read aloud as\n "black parallelogram black parallelogram\u2026" before the company name. Use\n `lb-vh` for any label that should be heard but not seen.\n\n## Recipe: cold-call sheet (one row per lead)\n\n```js\nconst lb = window.LeadbayArtifacts; lb.configure();\nconst ASK = "<the user\'s request>";\n\nconst list = lb.callList({ source: "campaign", campaignId: CID, ask: ASK });\nlist.subscribe((l) => renderRows(l.items, l.loading)); // your render\n\n// per lead row (call when you build a row):\nfunction wireRow(lead, els) {\n const status = lb.field({ value: "STILL_CHASING" }); // static-enum <select>\n const note = lb.field({ validate: (v) => (v && v.trim() ? null : "Add a note") });\n lb.bindValue(els.status, status);\n lb.bindValue(els.note, note);\n lb.bindAction(els.log, lb.outreach({ leadId: lead.id, ask: ASK, status, note }));\n lb.bindAction(els.like, lb.like(lead.id));\n\n const history = lb.leadHistory(lead.id, ASK); // lazy\n history.subscribe((h) => renderHistory(els.history, h));\n els.expand.onclick = () => history.load(); // load on click\n}\n```\n\n## Recipe: lead-status dropdown (Wanted / Won / Lost)\n\nThe org-wide CRM status, as a `<select>` + Apply button. You write the markup;\n`lb.leadStatus` fills the options and holds the value, `lb.setStatus` does the write.\n\n```html\n<div class="lb-card">\n <div class="lb-card-head">\n <span class="lb-title">Acme Corp</span>\n <span class="lb-chips">\n <span id="taste" class="lb-chip" data-taste="liked">Liked</span>\n <span id="crm" class="lb-chip" data-status="WANTED">Wanted</span>\n </span>\n </div>\n <div class="lb-row">\n <select id="st" class="lb-select"></select>\n <button id="go" class="lb-btn">Apply</button>\n <span id="msg" class="lb-msg"></span>\n </div>\n</div>\n```\n\n**Two badges, never one.** Taste (`liked`/`disliked`, from `lb.like`/`lb.dislike`)\nand CRM status (`WANTED`/`WON`/`LOST`/`UNWANTED`, from `lb.setStatus`) are\nindependent axes \u2014 a lead can be liked *and* lost. Collapsing them into a single\nchip destroys information: the rep can no longer see that a lead they liked went\nnowhere. Render `data-taste` and `data-status` as separate chips inside\n`lb-chips`, and hide the one that has no value rather than reusing it.\n\n```js\nlb.styles(); // once per artifact \u2014 see below\n\nconst status = lb.leadStatus(lead.org_lead_status); // seed with the CURRENT value\nconst save = lb.setStatus({ leadId: lead.id, status, ask: ASK });\n\nlb.bindSelect(document.getElementById("st"), status); // populates the 4 options\nlb.bindAction(document.getElementById("go"), save); // click \u2192 write\n\nsave.subscribe((a) => { // render your own feedback\n msg.textContent = a.loading ? "Saving\u2026"\n : a.error ? a.error.message // includes partial failures\n : a.lastResult ? `Set to ${a.lastResult.status}` : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nLoading / success / error styling comes free: `bindAction` and `bindSelect` set\n`data-lb-state` (`ready|loading|error|success|unavailable`) and the skin already\ntargets those attributes. No extra wiring.\n\n**Save on change is the default for status.** The rep picks a value and it\nwrites \u2014 one interaction, no second button, matching the web app\'s own status\nselector. Drop `bindAction` and run the action from the change event:\n\n```js\nconst sel = document.getElementById("st");\nsel.addEventListener("change", () => save.run());\nsave.subscribe((a) => { // the select IS the feedback surface\n sel.setAttribute("data-lb-state",\n a.loading ? "loading" : a.error ? "error" : a.lastResult ? "success" : "ready");\n msg.textContent = a.loading ? "Saving\u2026" : a.error ? a.error.message\n : a.lastResult ? "Saved" : "";\n msg.dataset.tone = a.error ? "error" : a.lastResult ? "ok" : "";\n});\n```\n\nWithout a submit button the select becomes the only affordance, so it MUST show\nthe write: mirror `data-lb-state` onto it (the skin already styles loading /\nsuccess / error on `.lb-select`) and put the outcome in the `role="status"`\nline. A silent select leaves the rep unsure whether anything happened.\n\nKeep a submit button ONLY where a mis-click is expensive and the value is not\nself-evident \u2014 a bulk apply across checked rows, for instance, which already\ntakes a `confirm`. For one lead, the extra step buys nothing: the value is\nvisible in the select, and the rep can simply pick again.\n\n**Bulk apply** across checked rows \u2014 pass `leadIds` and a `confirm`, since one\nclick rewrites a field every rep in the org sees:\n\n```js\nconst bulk = lb.setStatus({\n leadIds: () => checkedIds, // \u2190 read at run() time, not at build time\n status, ask: ASK,\n confirm: "Set this status on every selected lead?",\n});\n```\n\n`leadIds` is read when the action runs, so a live selection works \u2014 but pass the\narray itself if your selection is fixed. A partial write (some leads rejected)\nsurfaces as `.error`, never as a green button: `setStatus` checks the `failed[]`\nthe tool returns.\n\nThe backend stamps the status date as "now" on every write, which is what a rep\nclicking a dropdown means. Don\'t add a date picker unless the user asks to\nbackdate \u2014 then pass an optional `date` field holding `YYYY-MM-DD`:\n`lb.setStatus({ leadId, status, date, ask })`.\n\n## Recipe: manager dashboard\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.loading) showSpinner();\n if (t.data) {\n renderLeaderboard(t.data.reps); // sorted by total_activities; cols: name, notes, meetings_or_interest, lost\u2026\n renderTrendChart(t.data.trend); // [{date,count}] \u2192 Chart.js (allowed from CDN)\n }\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\n## Recipe: live enrichment\n\n```js\nconst job = lb.enrichment({ leadIds: [LEAD], titles: ["CEO", "VP Sales"], ask: ASK });\njob.subscribe((j) => {\n const p = j.data && j.data.overall_progress; // {done,total,done_ratio}\n renderBar(p);\n if (j.done) renderContacts(j.data.leads); // enriched contacts\n});\nrefreshBtn.onclick = () => job.refresh();\n```\n\n## Write-call rules\n\nThe domain factories handle these for you. If you hand-roll an action:\n`leadbay_report_outreach` args MUST include `verification:{source:"user_confirmed", ref}`\nAND `_triggered_by`; `leadbay_add_leads_to_campaign` needs `_triggered_by`;\n`add_note`/`like_lead`/`dislike_lead` take only their own args. `epilogue_status` is\none of `lb.EPILOGUE_STATUSES`. Snoozing (pushback) is advanced-gated \u2014 not\ncallable from a default artifact. Org lead status IS on the default surface:\nuse `lb.setStatus`, which owns the arg shape AND the partial-write check \u2014\n`leadbay_set_lead_status` writes each lead separately, so it can resolve 200\nwith a non-empty `failed[]`. Hand-rolling that action will report a green\nbutton over a write that never landed.\n\n## Degradation + live updates\n\nIf the host bridge is absent, a view-model\'s `.error` is set with `.error.unavailable\n=== true` (bind helpers set `data-lb-state="unavailable"`) \u2014 nothing throws. Every\ncall also has a **30s timeout** (configurable via `lb.configure({ timeoutMs })`): a\nhost call that never settles becomes `.error` with `code:"timeout"`, so a control is\nnever stuck loading forever \u2014 always render the `.error` branch so the user can retry.\nAuto-poll (`pollEvery`) depends on the cowork host serving FRESH reads; `.refresh()`\nis the guaranteed manual path \u2014 always wire a Refresh control for polling resources.';
|
|
26046
26292
|
|
|
26047
26293
|
// ../core/dist/tools/artifact-kit.js
|
|
26048
26294
|
var artifactKit = {
|
|
@@ -27027,7 +27273,7 @@ function buildAcknowledgeUpdateTool(opts) {
|
|
|
27027
27273
|
var ENRICHMENT_TERMINAL = `A settled-empty enrichment is TERMINAL \u2014 do not re-attempt it on a later run. On any contact record, \`enrichment.done: true\` together with \`enrichment.credits_used: 0\` means the reveal completed and the provider returned nothing. Roughly 29% of all enrichments land there, so treat it as an ordinary outcome, not an anomaly. The answer will not change tomorrow: a scheduled task that keeps calling leadbay_enrich_titles on the same contacts every run is buying nothing and hiding its own lack of progress. Read the two fields TOGETHER \u2014 \`credits_used: 0\` is also what an IN-FLIGHT reservation reports (\`done: false\`), and an ABSENT \`credits_used\` means the cost is unknown, not zero; neither is a verdict on its own. \`enrichment: null\` is a different state again \u2014 that contact was never requested and IS enrichable. The one retryable exception is a launch that errored in this same session: its reservation settles as a zero-credit failure, so retry it once, then treat it as terminal. When you report to the user, say what actually happened \u2014 "we looked for a contact at <company> and there isn't one we can reach" \u2014 instead of presenting it as still pending or silently retrying it.`;
|
|
27028
27274
|
var FRICTION = `Problem reports: when the user asks you to report a Leadbay problem ("report this", "tell the team this didn't work"), call leadbay_report_friction with {category, message (the user's own words), tool_called?, severity?}. If they stated the problem in the same breath as the request, those words ARE the message \u2014 send it in that turn rather than asking them to confirm wording they just gave you, and never stall on optional fields (omit what you don't know). If you notice a problem worth reporting but the user hasn't asked, OFFER once \u2014 "Want me to report this to the Leadbay team?" \u2014 and call it only if they agree. Never call it unprompted. Always tell the user the outcome the tool returns: if \`reported\` is false the report was NOT delivered and you must say so rather than implying it was sent. Frustration alone is not a reason to call it: keep solving their ask.`;
|
|
27029
27275
|
var MENTAL_MODEL = `How Leadbay works (mental model): Leadbay is a sales inbox, not a queryable database. Each day the user logs back in, a fresh batch of leads is delivered. Batch size is paced by how many leads the user has actually acted on recently \u2014 some workflows produce a big stream of smaller prospects, others a narrow stream of bigger ones. Pulling more won't produce more; the user acting on leads (outreach, skips, saves) does.`;
|
|
27030
|
-
var QUOTA_REFRESH = `Show the refreshed quota AFTER a
|
|
27276
|
+
var QUOTA_REFRESH = `Show the refreshed quota AFTER a quota-using action only when it matters: the user asks where they now stand, the action stopped on an exhausted window, or a top-up the user confirmed landed. Otherwise report the result and say nothing about quota. When you do show it, wait for genuine completion \u2014 leadbay_bulk_enrich_status reports the job done (all_done, OR a plateau you've decided is terminal: overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) \u2014 then call leadbay_account_status once and render the per-window %/$ gauge (Daily/Weekly/Monthly) it returns. leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it \u2014 the usage isn't reflected yet. For that single-contact flow, refresh only once a re-read of the lead's contacts (leadbay_research_lead_by_id; leadbay_get_contacts where exposed) shows the REQUESTED channel actually landed \u2014 the requested email and/or phone_number present \u2014 NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action \u2014 not after every poll while work is still in progress.`;
|
|
27031
27277
|
var QUOTA_TOPUP = `Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options \u2014 wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY \u2014 they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' \u2014 when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits \u2014 even WITHOUT re-calling account_status \u2014 treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep.`;
|
|
27032
27278
|
var TRANSIENT_401 = `Transient 401s (DO NOT narrate as an auth problem): Leadbay bearer tokens do NOT expire on a timer, and the client already auto-retried this call once before surfacing it. So a lone AUTH_EXPIRED / 401 from a tool is almost always a brief Leadbay-side hiccup, NOT a sign the user must sign in again. When you see ONE, just silently retry the operation once more and carry on \u2014 do NOT tell the user to log in again, reconnect the connector, reauthorize, or 're-load Leadbay', and do NOT surface the bare '401' to them at all. Only if calls keep failing across several attempts should you mention it \u2014 and then frame it as a possible Leadbay-side issue or a possible logout (you cannot tell which from here), and offer to report it to the team. Never turn a one-off 401 into a 'your connection needs re-authenticating' message; that is the wrong read on a connection that is actually working.`;
|
|
27033
27279
|
var TRIGGERED_BY = `Trigger provenance (MANDATORY): every Leadbay composite-tool call MUST carry a non-empty \`_triggered_by\` argument \u2014 the verbatim slice of the user's most recent message that this call is acting upon. Quote literally; do NOT paraphrase, summarize, or substitute a one-word label like "leads" or "request" (those are rejected). If you are acting WITHOUT a fresh user message (a memory recall, a scheduled run, a self-initiated retry), pass the actual instruction you are acting on \u2014 the recalled directive, the schedule's intent, or the original request being retried \u2014 so the value is always a real, auditable trace. Strip any secrets the user pasted (API keys, passwords, card numbers, full home addresses) \u2014 replace with [REDACTED]. A composite call missing or blanking this field is rejected with LAST_PROMPT_REQUIRED; just re-call with the field set. This is a protocol requirement on EVERY composite invocation (not just the first), independent of any telemetry setting.`;
|
|
@@ -27166,7 +27412,7 @@ function buildProtocolPrimitivesParagraph(has) {
|
|
|
27166
27412
|
}
|
|
27167
27413
|
if (deliveryRunners.length > 0) {
|
|
27168
27414
|
clauses.push(
|
|
27169
|
-
"On " + deliveryRunners.map((n) => `leadbay_${n}`).join(", ") + " the job is BACKEND-owned and likewise keeps running. Any work already
|
|
27415
|
+
"On " + deliveryRunners.map((n) => `leadbay_${n}`).join(", ") + " the job is BACKEND-owned and likewise keeps running. Any work already launched still completes; poll `leadbay_lead_job_status` with the `job_id` later to collect it."
|
|
27170
27416
|
);
|
|
27171
27417
|
}
|
|
27172
27418
|
parts.push(
|
|
@@ -28126,7 +28372,7 @@ function parseWriteEnv(env = process.env) {
|
|
|
28126
28372
|
}
|
|
28127
28373
|
|
|
28128
28374
|
// src/http-server.ts
|
|
28129
|
-
var VERSION = true ? "0.38.
|
|
28375
|
+
var VERSION = true ? "0.38.2" : "0.0.0-dev";
|
|
28130
28376
|
var PORT = Number(process.env.PORT ?? 8080);
|
|
28131
28377
|
var HOST = process.env.HOST ?? "0.0.0.0";
|
|
28132
28378
|
var logger = {
|