@leadbay/mcp 0.39.9 → 0.39.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +933 -195
- package/dist/http-server.js +918 -183
- package/dist/installer-electron.js +1 -1
- package/dist/installer-gui.js +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -12,6 +12,7 @@ var __export = (target, all) => {
|
|
|
12
12
|
// ../core/dist/client.js
|
|
13
13
|
import https from "https";
|
|
14
14
|
import { AsyncLocalStorage } from "async_hooks";
|
|
15
|
+
import { createHash } from "crypto";
|
|
15
16
|
import { readdirSync, readFileSync, existsSync } from "fs";
|
|
16
17
|
import { join } from "path";
|
|
17
18
|
function defaultTimeoutMs() {
|
|
@@ -368,6 +369,12 @@ var init_client = __esm({
|
|
|
368
369
|
get isAuthenticated() {
|
|
369
370
|
return this.token !== null;
|
|
370
371
|
}
|
|
372
|
+
// Who is calling, without a request: a digest of the bearer token, or null
|
|
373
|
+
// without one. Hosted builds a new client for every request, so a count kept
|
|
374
|
+
// across one caller's calls is keyed on this, never on the client.
|
|
375
|
+
callerKey() {
|
|
376
|
+
return this.token === null ? null : createHash("sha256").update(this.token).digest("hex");
|
|
377
|
+
}
|
|
371
378
|
// Test-only getter for concurrency assertions
|
|
372
379
|
get _semaphoreState() {
|
|
373
380
|
return { active: this.activeRequests, queued: this.waitQueue.length };
|
|
@@ -604,7 +611,7 @@ var init_client = __esm({
|
|
|
604
611
|
}
|
|
605
612
|
return JSON.parse(res.body);
|
|
606
613
|
} catch (e) {
|
|
607
|
-
throw this.mapTransportError(e, `${method} ${path}
|
|
614
|
+
throw this.mapTransportError(e, `${method} ${path}`, grantedBudget());
|
|
608
615
|
} finally {
|
|
609
616
|
if (held.value)
|
|
610
617
|
this.releaseSemaphore();
|
|
@@ -751,11 +758,11 @@ var init_client = __esm({
|
|
|
751
758
|
* (auth-http.ts) keeps classifying it as a transient fault and moves to the
|
|
752
759
|
* sibling region instead of declaring a live token expired.
|
|
753
760
|
*/
|
|
754
|
-
mapTransportError(e, endpoint) {
|
|
761
|
+
mapTransportError(e, endpoint, grantedMs) {
|
|
755
762
|
const err = e;
|
|
756
763
|
if (err?.code !== "TIMEOUT")
|
|
757
764
|
return e;
|
|
758
|
-
const ms = err.timeout_ms ?? defaultTimeoutMs();
|
|
765
|
+
const ms = grantedMs ?? err.timeout_ms ?? defaultTimeoutMs();
|
|
759
766
|
const envelope = this.makeError("TIMEOUT", `Leadbay did not respond within ${ms}ms \u2014 the request was cancelled`, "The connection was accepted but no response came back, so this is a Leadbay-side stall, not a bad request. It is transient: retry the same call once. If it times out again, tell the user Leadbay is not responding right now and offer to report it with leadbay_report_friction.", endpoint);
|
|
760
767
|
if (envelope._meta) {
|
|
761
768
|
envelope._meta.timeout_ms = ms;
|
|
@@ -1621,13 +1628,14 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
1621
1628
|
|
|
1622
1629
|
## RENDER (quick)
|
|
1623
1630
|
|
|
1624
|
-
Report user + org
|
|
1625
|
-
|
|
1631
|
+
Report user + org + one line on what the account targets
|
|
1632
|
+
(\`search_configuration\`). Show quota only when asked (quota/account status)
|
|
1633
|
+
or a window is exhausted. NEVER mention the lens unless asked (use
|
|
1626
1634
|
\`last_requested_lens_name\`, never the id). SILENT on quota when
|
|
1627
1635
|
\`quota_error\` set, \`unlimited_credits\` true, or quota null. Else render
|
|
1628
|
-
Daily/Weekly/Monthly from \`quota.user\` (
|
|
1629
|
-
|
|
1630
|
-
|
|
1636
|
+
Daily/Weekly/Monthly from \`quota.user\` (else \`quota.org\`) as
|
|
1637
|
+
\`$used / $cap (N% used) \xB7 resets\` (resource-count table if \`spend[]\`
|
|
1638
|
+
empty). Never say raw "credits".
|
|
1631
1639
|
|
|
1632
1640
|
---
|
|
1633
1641
|
|
|
@@ -1639,6 +1647,8 @@ Show the user's account state \u2014 admin rights, language, last-active lens, q
|
|
|
1639
1647
|
|
|
1640
1648
|
**After a user tops up, do NOT keep refusing \u2014 RETRY.** If the user signals they topped up / bought credits / added credits, the previous QUOTA_EXCEEDED is invalidated the moment the Stripe webhook lands. RE-CALL \`leadbay_account_status\` to pick up the new state AND retry the originally failed call. The retry itself does not require a successful account_status check first \u2014 a topped-up user has cleared the throttle whether or not your cached snapshot reflects it yet. If the retry hits the wall again, only then re-offer top-up / wait. **A stale quota snapshot is never a reason to gate-keep a topped-up user.**
|
|
1641
1649
|
|
|
1650
|
+
**\`search_configuration\`** is what this account is set up to find: the ideal buyer profile, the targeting prompt, the qualification questions and the buying signals Leadbay watches for. Even when the user only asked whether Leadbay is connected, it is how you tell them what Leadbay is looking for on their behalf. A null field is unset or unreadable: never tell the user it is missing.
|
|
1651
|
+
|
|
1642
1652
|
**\`mcp_version\`** is the version of the Leadbay MCP server answering the call. When the user asks which Leadbay version they are running, answer with it.
|
|
1643
1653
|
|
|
1644
1654
|
**\`notifications\` block.** The response now includes a top-level \`notifications\` array listing background work the user (or agent) initiated that has since completed (\`bulk_enrich\`, \`bulk_qualify\`, \`import\`). These are signals to revise prior agent outputs the just-finished work might have made stale \u2014 they're NOT a pending-task list for the user. After revising (or confirming nothing is affected), call \`leadbay_acknowledge_notification(notification_id)\`. Full handling protocol below.
|
|
@@ -1859,7 +1869,7 @@ WHEN NOT TO USE: to create a NEW campaign (use \`leadbay_create_campaign\` with
|
|
|
1859
1869
|
`;
|
|
1860
1870
|
leadbay_add_note = `Add a note to a lead. Notes are visible to the whole organization in Leadbay.
|
|
1861
1871
|
|
|
1862
|
-
WHEN TO USE: low-level \u2014 for free-form notes not tied to outreach actions, including meaningful per-lead notes/context preserved from an imported file after the import returns lead IDs.
|
|
1872
|
+
WHEN TO USE: low-level \u2014 for free-form notes not tied to outreach actions, including meaningful per-lead notes/context preserved from an imported file after the import returns lead IDs. Also to record why a company was set aside when a CRM status change carries no reason, or when the user tells you something about one company that only they know (their own sales agent, already a client) without excluding it. A dislike saves its own reason: do not add a second note for it.
|
|
1863
1873
|
|
|
1864
1874
|
WHEN NOT TO USE: to log an outreach action \u2014 use leadbay_report_outreach, which requires verification (gmail/calendar/user_confirmed) to prevent hallucinated outreach poisoning the SDR pipeline.
|
|
1865
1875
|
|
|
@@ -1921,7 +1931,7 @@ On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\
|
|
|
1921
1931
|
Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` \u2014 text matches, not geo filters.
|
|
1922
1932
|
|
|
1923
1933
|
|
|
1924
|
-
**Widening to the whole workspace is NOT "pass no locations".** Location criteria MERGE here rather than replace, so any geography the lens already carries survives an edit that simply omits \`locations\`. "Make this healthcare nationwide" on a lens scoped to Paris returns Paris healthcare \u2014 and calling that nationwide is the same confidently-wrong answer as the country fence itself, just in the header instead of the filter. Read
|
|
1934
|
+
**Widening to the whole workspace is NOT "pass no locations".** Location criteria MERGE here rather than replace, so any geography the lens already carries survives an edit that simply omits \`locations\`. "Make this healthcare nationwide" on a lens scoped to Paris returns Paris healthcare \u2014 and calling that nationwide is the same confidently-wrong answer as the country fence itself, just in the header instead of the filter. Read the lens's \`criteria\` in \`leadbay_my_lenses\` FIRST, which names its \`location_ids\` (so does the \`lens://<lensId>/definition\` resource on hosts that expose one; \`leadbay_pull_leads\` returns only \`lens: {id}\`). Then either clear those criteria explicitly, or state which places the audience actually covers. If you cannot read the criteria, say the scope is unverified rather than calling it workspace-wide.
|
|
1925
1935
|
|
|
1926
1936
|
WHEN TO USE: when the user wants to see different kinds of leads (sector / size / geography / etc.).
|
|
1927
1937
|
|
|
@@ -2357,7 +2367,7 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
|
|
|
2357
2367
|
`;
|
|
2358
2368
|
leadbay_create_campaign = `## WHEN TO USE
|
|
2359
2369
|
|
|
2360
|
-
Trigger phrases: "create a campaign called <name>", "save these leads as a campaign", "campaign for my <city> trip", "group these leads", "persist these leads".
|
|
2370
|
+
Trigger phrases: "create a campaign called <name>", "save these leads as a campaign", "campaign for my <city> trip", "group these leads", "persist these leads", "prepare <N> campaigns to re-engage my prospects".
|
|
2361
2371
|
|
|
2362
2372
|
Do NOT use for: "list campaigns" \u2192 \`leadbay_list_campaigns\`; "add to existing campaign" \u2192 \`leadbay_add_leads_to_campaign\`; "log outreach" \u2192 \`leadbay_report_outreach\`.
|
|
2363
2373
|
|
|
@@ -2367,6 +2377,7 @@ Examples that SHOULD invoke this tool:
|
|
|
2367
2377
|
- "Save these 9 leads as a campaign called 'Limoges Tour \u2013 May 24'."
|
|
2368
2378
|
- "Create a campaign for the qualified leads I just picked."
|
|
2369
2379
|
- "Make a campaign for my SF visit and add those three accounts."
|
|
2380
|
+
- "Prepare 3 email campaigns to re-engage my prospects."
|
|
2370
2381
|
|
|
2371
2382
|
Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
2372
2383
|
- "What campaigns do I have?"
|
|
@@ -2377,9 +2388,12 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
2377
2388
|
|
|
2378
2389
|
One-line confirmation: \u2705 Created **<name>** with N leads \xB7 <id-short>.
|
|
2379
2390
|
If the backend AI-generated the name (no \`name\` passed), surface that:
|
|
2380
|
-
"AI-suggested name: <name>".
|
|
2381
|
-
|
|
2382
|
-
|
|
2391
|
+
"AI-suggested name: <name>". When the user asked for email campaigns,
|
|
2392
|
+
put that campaign's draft email (subject + body) under its line
|
|
2393
|
+
rather than offering to write it. Otherwise offer a NEXT STEPS chip
|
|
2394
|
+
for "Add more leads" or "Open in web UI". Don't dump the full campaign
|
|
2395
|
+
payload as a table \u2014 the user only needs the confirmation + the id for
|
|
2396
|
+
follow-up.
|
|
2383
2397
|
|
|
2384
2398
|
---
|
|
2385
2399
|
|
|
@@ -2394,6 +2408,8 @@ Create a new campaign \u2014 a server-persisted grouping of leads the user (or t
|
|
|
2394
2408
|
- Seed with \`lead_ids: [...]\` when the user already picked the leads (chain after \`leadbay_tour_plan\`, \`leadbay_pull_leads\`, \`leadbay_research_lead_by_id\`).
|
|
2395
2409
|
- Create empty (\`lead_ids: []\`, default) and add later via \`leadbay_add_leads_to_campaign\` \u2014 useful when the user named the campaign first and wants to populate it incrementally.
|
|
2396
2410
|
|
|
2411
|
+
**Campaigns to re-engage the user's prospects** \u2014 *"prepare 3 email campaigns to chase my prospects"*, *\xAB pr\xE9parer 3 campagnes de mails pour relancer mes prospects \xBB*. These prospects are leads the user already works, so read them with \`leadbay_pull_followups({filtered:false, order:"LAST_PROSPECTING_ACTION_AT:DESC"})\` unless the user named a subset. \`leadbay_list_campaigns\` only shows what exists; it does not answer this ask. Split the leads into the number of campaigns asked for by where each one stands: \`epilogue_status\`, then how long ago \`last_prospecting_action_at\` was, with never-contacted leads as their own group. When nearly every lead shares one state, pick another split that serves an email, such as sector or whether a named contact has an email. Call this tool once per campaign with a descriptive \`name\` and that group's \`lead_ids\`. Then, in the same answer, draft the email for each campaign: a subject and a body written for that group's situation, through \`message_compose_v1\` when the host offers it. The ask is the authorization for all of this: do not stop to ask how to split, which leads to include, or whether to draft. State the split in one line and deliver.
|
|
2412
|
+
|
|
2397
2413
|
**Scope**: campaigns are created in the caller's organization and \`created_by = caller_user_id\`. The list endpoint (\`leadbay_list_campaigns\`) is filtered to the creator \u2014 campaigns ARE NOT shared with teammates by default. For #3630 US3 "manager creates a campaign for a rep", today's MCP workaround is to name campaigns descriptively ("North-East \u2013 John") and have the rep visit /app to access via the web UI; cross-user assignment would need backend work.
|
|
2398
2414
|
|
|
2399
2415
|
---
|
|
@@ -2482,7 +2498,7 @@ Trigger phrases: "I don't like this lead", "thumbs down", "not relevant", "wrong
|
|
|
2482
2498
|
|
|
2483
2499
|
Do NOT use for: "remind me later / snooze / not now" \u2192 \`leadbay_set_pushback\`; "thumbs up / save this one" \u2192 \`leadbay_like_lead\`.
|
|
2484
2500
|
|
|
2485
|
-
Prefer when: durable rejection of a specific lead; pass \`lead_id\`. For temporary deferral, route to \`leadbay_set_pushback\`.
|
|
2501
|
+
Prefer when: durable rejection of a specific lead; pass \`lead_id\`, and the user's words as \`reason\`. For temporary deferral, route to \`leadbay_set_pushback\`.
|
|
2486
2502
|
|
|
2487
2503
|
Examples that SHOULD invoke this tool:
|
|
2488
2504
|
- "Thumbs down \u2014 wrong industry."
|
|
@@ -2497,16 +2513,19 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
2497
2513
|
## RENDER (quick)
|
|
2498
2514
|
|
|
2499
2515
|
One short confirmation line after the call returns ("\u{1F44E} Disliked **{company_name}** \u2014
|
|
2500
|
-
|
|
2501
|
-
|
|
2516
|
+
reason saved as a note." when \`reason_saved\`). If the reason names a kind of
|
|
2517
|
+
company, add one line proposing the setting change (see \`targeting\`). Then
|
|
2518
|
+
continue the current flow (don't dump the full lead card again).
|
|
2502
2519
|
|
|
2503
2520
|
---
|
|
2504
2521
|
|
|
2505
|
-
Mark a lead as disliked. This is the same action as clicking the thumbs-down on the Leadbay website. The
|
|
2522
|
+
Mark a lead as disliked. This is the same action as clicking the thumbs-down on the Leadbay website: the lead stops appearing for this user. The dislike itself carries no reason and changes no lens filter, qualification question or targeting prompt. Leadbay only avoids a sector on the lens's next refresh after two of the lens's leads in it are disliked and none liked, and a city after two.
|
|
2506
2523
|
|
|
2507
|
-
Pass the lead's UUID as \`lead_id
|
|
2524
|
+
Pass the lead's UUID as \`lead_id\`, and the user's reason as \`reason\` whenever they gave one. The reason is saved as a note on the lead, so the team and later sessions can read why. Do not also call \`leadbay_add_note\` for it.
|
|
2508
2525
|
|
|
2509
|
-
|
|
2526
|
+
**A reason about a kind of company is a targeting rule.** "Consulting firm, off ICP", "too small", "subsidiary of a large group", "wrong region" will be true of the next lead too. Disliking does not stop Leadbay proposing the next one. First check whether the org's criteria already reject it: with a \`reason\`, the response carries the lead's \`ai_score\`, and below 0 means qualification already scored the lead against them, so there is nothing to change. Otherwise call \`leadbay_get_qualification_questions\`. If no anti-pattern or question already says it, propose adding the reason as a negative criterion of the ideal buyer profile with \`leadbay_set_qualification_questions({add_anti_patterns})\`, and write it on the user's yes. Whether to propose is your judgement. A reason about this company alone ("unpaid invoice with them", "opposed to prospecting", "already our client") needs only the dislike and its reason.
|
|
2527
|
+
|
|
2528
|
+
Dislike is a **permanent** rejection of this lead. If the user only wants to defer a lead ("not now", "remind me next month", "I'll look at this one later"), call \`leadbay_set_pushback\` instead; that's reversible and time-bounded.
|
|
2510
2529
|
|
|
2511
2530
|
WHEN TO USE: the user explicitly rejects a lead ("not relevant", "wrong industry", "too small", "thumbs down", "skip this", "not a fit"). Use proactively after \`leadbay_research_lead\` reveals disqualifying signals.
|
|
2512
2531
|
|
|
@@ -2992,17 +3011,18 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
|
|
|
2992
3011
|
|
|
2993
3012
|
|
|
2994
3013
|
|
|
2995
|
-
Pick the 2-3
|
|
3014
|
+
Pick the 2-3 that match what happened, never the whole table:
|
|
2996
3015
|
|
|
2997
3016
|
| Observation | Suggest | Calls |
|
|
2998
3017
|
|---|---|---|
|
|
3018
|
+
| \u2265 1 delivered \u2014 offer FIRST | "Build an interactive lead triage board" | leadbay_artifact_kit \u2192 CANONICAL recipe, data in hand |
|
|
2999
3019
|
| Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
|
|
3000
3020
|
| 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}) |
|
|
3001
3021
|
| Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
|
|
3002
3022
|
| Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
|
|
3003
3023
|
| 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) |
|
|
3004
|
-
| Stopped on org quota (\`stop_reason: quota\`) | "Check which window is exhausted and when it resets" \u2014 never a re-run: it cannot clear
|
|
3005
|
-
| Stopped on
|
|
3024
|
+
| Stopped on org quota (\`stop_reason: quota\`) | "Check which window is exhausted and when it resets" \u2014 never a re-run: it cannot clear a quota and burns a submit slot to stop in the same place | leadbay_account_status |
|
|
3025
|
+
| Stopped on quota and the user will not wait | "Top up to finish this run" | leadbay_create_topup_link |
|
|
3006
3026
|
| User wants these tracked in Leadbay | "Add the keepers to a campaign" | leadbay_create_campaign / leadbay_add_leads_to_campaign |
|
|
3007
3027
|
`;
|
|
3008
3028
|
leadbay_followups_map = `## WHEN TO USE
|
|
@@ -3380,7 +3400,8 @@ Returns:
|
|
|
3380
3400
|
Say so and offer a starter set.
|
|
3381
3401
|
- **\`ideal_buyer_profile\`** \u2014 \`{summary, key_characteristics, anti_patterns}\`
|
|
3382
3402
|
or null. The questions score against THIS. A rule the user states is often
|
|
3383
|
-
already an \`anti_pattern\` here.
|
|
3403
|
+
already an \`anti_pattern\` here. Add one with
|
|
3404
|
+
\`leadbay_set_qualification_questions({add_anti_patterns})\`.
|
|
3384
3405
|
- **\`targeting_prompt\`** \u2014 the org's free-text instruction to the AI agent, or
|
|
3385
3406
|
null. Qualitative rules that no single yes/no can express live here; change
|
|
3386
3407
|
it with \`leadbay_refine_prompt\`.
|
|
@@ -3424,15 +3445,16 @@ already covered without seeing them.
|
|
|
3424
3445
|
|---|---|
|
|
3425
3446
|
| A sector, a headcount band, a territory | \`leadbay_adjust_audience\` / \`leadbay_new_lens\` filters \u2014 never a question |
|
|
3426
3447
|
| A company trait a stranger could estimate from that company's own website or registry record \u2014 "runs its own maintenance crew", "operates a large vehicle fleet", "is legally active and not in liquidation" | a qualification question |
|
|
3448
|
+
| A kind of company that is never the buyer \u2014 "consulting firms", "franchise locations of national chains", "subsidiaries of large listed groups" | a negative criterion of the ideal buyer profile, \`leadbay_set_qualification_questions({add_anti_patterns})\`. It uses no question slot, and qualification reads it as a negative signal |
|
|
3427
3449
|
| A qualitative orientation too broad for one yes/no \u2014 "we sell to the private sector, not the public one", "harden the exclusion on the business model" | the targeting prompt, \`leadbay_refine_prompt\` |
|
|
3428
|
-
| Named companies \u2014 "exclude Groupe Solidum, Dentego" | \`leadbay_dislike_lead\` / \`leadbay_set_lead_status\` on those leads. A question must NEVER name a company |
|
|
3450
|
+
| Named companies \u2014 "exclude Groupe Solidum, Dentego" | \`leadbay_dislike_lead\` with the user's words as \`reason\` / \`leadbay_set_lead_status\` on those leads. A status stores no reason, so add the user's reason with \`leadbay_add_note\`. A question must NEVER name a company |
|
|
3429
3451
|
| CRM state \u2014 "already contacted", "already in a campaign", "already excluded" | read it: \`leadbay_pull_followups\`, \`leadbay_list_campaigns\`. A question cannot observe your own history |
|
|
3430
3452
|
| A delivery requirement \u2014 "email AND phone mandatory", "only score 54\u201395" | enrichment plus your own post-filter of the result. A question scores the COMPANY; it cannot see whether Leadbay holds a phone number for a contact |
|
|
3431
3453
|
| An event or purchase trigger \u2014 "currently hiring an SDR", "just opened a site" | a qualification question or the targeting prompt. NEVER an \`example_lead\` description or a \`query\`: those match stable registry text, which never mentions events |
|
|
3432
3454
|
|
|
3433
3455
|
**3 \u2014 Decide whether to change anything at all.** Touching a question
|
|
3434
3456
|
re-scores every lead in the pipeline and draws on the org's quota, so a change
|
|
3435
|
-
that surfaces the same companies is a pure loss.
|
|
3457
|
+
that surfaces the same companies is a pure loss. Five reasons to write NOTHING
|
|
3436
3458
|
and say why:
|
|
3437
3459
|
|
|
3438
3460
|
1. **An existing question already covers the rule.** Quote that question back
|
|
@@ -3447,6 +3469,11 @@ and say why:
|
|
|
3447
3469
|
4. **The question the user asked for is not decisive.** See step 4: say so,
|
|
3448
3470
|
offer the sharper version, and write only what they then choose. Adding a
|
|
3449
3471
|
question you know separates nothing is worse than adding none.
|
|
3472
|
+
5. **Qualification already rejects the lead the user turned down.** A negative
|
|
3473
|
+
\`ai_score\` on \`leadbay_dislike_lead\`, or a negative \`ai_agent_lead_score\` in
|
|
3474
|
+
\`leadbay_research_lead_by_id\`, means the current questions and anti-patterns
|
|
3475
|
+
already score it against. An existing \`anti_patterns\` entry that says the same
|
|
3476
|
+
thing counts too.
|
|
3450
3477
|
|
|
3451
3478
|
**The ceiling is 5 questions.** Read the count before you answer an "add a
|
|
3452
3479
|
question" request: at 5 the honest answer is not "sure, I'll add it". Say in
|
|
@@ -4245,7 +4272,7 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
|
|
|
4245
4272
|
|
|
4246
4273
|
Trigger phrases: "what campaigns do I have", "list my campaigns", "show me my active campaigns", "campaign overview", "what's in flight", "pulse on my campaigns".
|
|
4247
4274
|
|
|
4248
|
-
Do NOT use for: "create a new campaign" \u2192 \`leadbay_create_campaign\`; "drill into one specific campaign's progression" \u2192 \`leadbay_campaign_progression\`.
|
|
4275
|
+
Do NOT use for: "create a new campaign" \u2192 \`leadbay_create_campaign\`; "drill into one specific campaign's progression" \u2192 \`leadbay_campaign_progression\`; "prepare campaigns to re-engage my prospects" \u2192 \`leadbay_create_campaign\`.
|
|
4249
4276
|
|
|
4250
4277
|
Prefer when: user wants the pulse / overview view across all their campaigns. Use campaign_progression to drill into one
|
|
4251
4278
|
|
|
@@ -4258,6 +4285,7 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
4258
4285
|
- "Create a 'Q2 Push' campaign."
|
|
4259
4286
|
- "How are leads progressing in my Limoges Tour?"
|
|
4260
4287
|
- "Today's fresh leads."
|
|
4288
|
+
- "Prepare 3 email campaigns to re-engage my prospects."
|
|
4261
4289
|
|
|
4262
4290
|
## RENDER (quick)
|
|
4263
4291
|
|
|
@@ -4373,14 +4401,15 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
|
|
|
4373
4401
|
`;
|
|
4374
4402
|
leadbay_my_lenses = `## WHEN TO USE
|
|
4375
4403
|
|
|
4376
|
-
Trigger phrases: "show me my lenses", "list my lenses", "which audiences do I have", "switch to my <name> lens", "change lens", "rename my <name> lens to <X>", "set the description of my <name> lens", "delete my <name> lens", "remove this lens".
|
|
4404
|
+
Trigger phrases: "show me my lenses", "list my lenses", "which audiences do I have", "what is my lens searching for", "what are the criteria of my <name> lens", "give me my current criteria", "switch to my <name> lens", "change lens", "rename my <name> lens to <X>", "set the description of my <name> lens", "delete my <name> lens", "remove this lens".
|
|
4377
4405
|
|
|
4378
4406
|
Do NOT use for: "narrow the audience" \u2192 \`leadbay_adjust_audience\`; "stop showing me <sector>" \u2192 \`leadbay_refine_prompt\`; "more leads on this lens" \u2192 \`leadbay_extend_lens\`; "show me today's leads" \u2192 \`leadbay_pull_leads\`.
|
|
4379
4407
|
|
|
4380
|
-
Prefer when: user wants to SEE lenses, CHANGE which is active, RENAME one, or DELETE one \u2014 not edit a lens's sector/size criteria
|
|
4408
|
+
Prefer when: user wants to SEE lenses or a lens's criteria, CHANGE which is active, RENAME one, or DELETE one \u2014 not edit a lens's sector/size criteria
|
|
4381
4409
|
|
|
4382
4410
|
Examples that SHOULD invoke this tool:
|
|
4383
4411
|
- "Show me my lenses."
|
|
4412
|
+
- "What criteria is my Manufacturing lens using? I want to add or remove some."
|
|
4384
4413
|
- "Rename my Auto lens to Automotive and add a description."
|
|
4385
4414
|
- "Delete my old Auto lens."
|
|
4386
4415
|
|
|
@@ -4392,7 +4421,8 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
4392
4421
|
## RENDER (quick)
|
|
4393
4422
|
|
|
4394
4423
|
Small markdown table, active lens first: col 1 = \u2B50 prefix when active +
|
|
4395
|
-
lens name; col 2 = description (or \`\u2014\`)
|
|
4424
|
+
lens name; col 2 = description (or \`\u2014\`); col 3 = its criteria by name.
|
|
4425
|
+
After a switch lead with
|
|
4396
4426
|
"Now showing **<name>**."; after a rename lead with the rename confirmation.
|
|
4397
4427
|
Full algorithm below.
|
|
4398
4428
|
|
|
@@ -4402,7 +4432,10 @@ List the user's lenses (saved audiences) and, when asked, switch which one is ac
|
|
|
4402
4432
|
|
|
4403
4433
|
**Three modes, one tool:**
|
|
4404
4434
|
|
|
4405
|
-
- **List (no args)** \u2014 pure read. Returns \`{status:"listed", lenses:[
|
|
4435
|
+
- **List (no args)** \u2014 pure read. Returns \`{status:"listed", lenses:[\u2026], active_lens_id}\`. The active lens is resolved from the user's last-requested lens, so \`is_active\` is authoritative.
|
|
4436
|
+
|
|
4437
|
+
**Every lens in every response carries** all its metadata (\`id\`, \`name\`, \`description\`, \`is_active\`, \`is_default\`, \`user_id\`, \`multi_product_mode\`, \`use_hq_only\`, and the backend's \`not_enough_lead_candidates\` / \`not_enough_new_leads\` / \`less_leads_than_targeted\` flags) plus **\`criteria\`**: its own filter as the Leadbay web app shows it, one entry per criterion \`{type, is_excluded, \u2026}\`. \`sector_ids\` and \`location_ids\` come as \`[{id, name}]\`; \`size\` as \`sizes:[{min,max}]\` (employees); any other type verbatim. \`criteria: []\` means the lens sets no criteria of its own; \`null\` means it could not be read. This is how to answer "what is this lens searching for", and the read before adding or removing a criterion with \`leadbay_adjust_audience\`.
|
|
4438
|
+
|
|
4406
4439
|
- **Switch (\`switchToLensId\`)** \u2014 changes the active lens to that id and returns the REFRESHED list. The id MUST be one of the user's lenses; an unknown id returns \`{status:"not_found"}\` with the current list \u2014 surface it and ask the user to pick, do NOT invent an id. Switching to the already-active lens is a harmless no-op.
|
|
4407
4440
|
- **Edit (\`editLensId\` + \`newName\` and/or \`newDescription\`)** \u2014 rename and/or set the description of a lens in one call, returns the REFRESHED list. Provide either or both; pass \`newDescription:""\` to clear a description. Same not_found handling. Use the \`id\` from the list for the lens the user named.
|
|
4408
4441
|
- **Delete (\`deleteLensId\`)** \u2014 DESTRUCTIVE and confirm-gated. Without \`confirm:true\` it returns \`status:"delete_preview"\` with \`will_delete\` and removes NOTHING \u2014 show it, get the user's explicit yes, then re-call with \`confirm:true\`. The DEFAULT lens cannot be deleted (\`status:"cannot_delete_default"\`). Deleting the active lens leaves no active lens until the next switch/pull resolves one.
|
|
@@ -4443,7 +4476,7 @@ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We
|
|
|
4443
4476
|
|
|
4444
4477
|
## RENDERING \u2014 lenses table, active-first
|
|
4445
4478
|
|
|
4446
|
-
Markdown table with
|
|
4479
|
+
Markdown table with THREE columns. Sort **active lens first**, then by \`name\`
|
|
4447
4480
|
ascending. **No score bar** \u2014 the \`\u25B0\u2756\u25B1\` glyph identity belongs to lead
|
|
4448
4481
|
discovery, not lenses.
|
|
4449
4482
|
|
|
@@ -4455,10 +4488,21 @@ discovery, not lenses.
|
|
|
4455
4488
|
- \`description\` verbatim, clipped to \u2264 18 words.
|
|
4456
4489
|
- When null/empty: render \`\u2014\`.
|
|
4457
4490
|
|
|
4491
|
+
**Column 3 \u2014 Searches for**
|
|
4492
|
+
- \`criteria\` by name, compact: sectors, then locations, then size
|
|
4493
|
+
(\`20\u2013500 employees\`). Prefix \`not \` when \`is_excluded\`. Past 3 sectors, add
|
|
4494
|
+
\`+N more\`. \`[]\` \u2192 \`no criteria of its own\`; \`null\` \u2192 \`\u2014\`.
|
|
4495
|
+
|
|
4458
4496
|
**After a \`switched: true\` response**, open with a single confirmation line
|
|
4459
4497
|
ABOVE the table: \`Now showing **<name>**.\` For \`status: "not_found"\`, lead with
|
|
4460
4498
|
the \`message\` (the bad id) and render the list so the user can pick a real one.
|
|
4461
4499
|
|
|
4500
|
+
**When the user asks what a lens searches for**: under the table, a
|
|
4501
|
+
\`**<lens name>** searches for:\` line, then one bullet per criterion \u2014
|
|
4502
|
+
\`Sectors:\`, \`Locations:\` (every name, joined by commas; \`name\` null \u2192 the id),
|
|
4503
|
+
\`Company size: 20\u2013500 employees\`. Prefix \`Excluding\` when \`is_excluded\`. Other
|
|
4504
|
+
types verbatim. Never show raw ids when a name exists.
|
|
4505
|
+
|
|
4462
4506
|
**Empty list** (\`lenses: []\`): render \`*You don't have any lenses yet.*\` \u2014 do not
|
|
4463
4507
|
render an empty table.
|
|
4464
4508
|
|
|
@@ -4491,6 +4535,7 @@ quick-select options (each option = a lens name \u2192 \`leadbay_my_lenses(switc
|
|
|
4491
4535
|
|
|
4492
4536
|
| Observation | Suggest | Calls |
|
|
4493
4537
|
|--------------------------------------|------------------------------------------|------------------------------------------------------|
|
|
4538
|
+
| User wants to add / remove a criterion| "Change <lens name>'s criteria" | \`leadbay_adjust_audience(lensId=<id>, \u2026)\` |
|
|
4494
4539
|
| User wants a different lens | "Switch to <lens name>" | \`leadbay_my_lenses(switchToLensId=<id>)\` |
|
|
4495
4540
|
| User wants to rename / describe a lens| "Rename or describe <lens>" | \`leadbay_my_lenses(editLensId=<id>, newName?=<X>, newDescription?=<Y>)\` |
|
|
4496
4541
|
| User wants to delete a lens | "Delete <lens>" | \`leadbay_my_lenses(deleteLensId=<id>)\` \u2192 confirm \u2192 \`confirm=true\` |
|
|
@@ -4561,7 +4606,7 @@ On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\
|
|
|
4561
4606
|
Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` \u2014 text matches, not geo filters.
|
|
4562
4607
|
|
|
4563
4608
|
|
|
4564
|
-
**A new lens is a CLONE, and inherits the base lens's geography.** \`base\` defaults to the ACTIVE lens, so this applies even when no base was named. A criteria-less clone inherits the base audience wholesale, and adding sectors does not clear the base's location criteria either \u2014 so "nationwide healthcare" built on a Paris-scoped active lens creates a Paris healthcare lens under a nationwide name. Omitting \`locations\` is therefore not the same as having no geography. Read \`lens://<base>/definition\` before describing a new lens as workspace-wide, and say the scope is unverified if you cannot.
|
|
4609
|
+
**A new lens is a CLONE, and inherits the base lens's geography.** \`base\` defaults to the ACTIVE lens, so this applies even when no base was named. A criteria-less clone inherits the base audience wholesale, and adding sectors does not clear the base's location criteria either \u2014 so "nationwide healthcare" built on a Paris-scoped active lens creates a Paris healthcare lens under a nationwide name. Omitting \`locations\` is therefore not the same as having no geography. Read the base's \`criteria\` in \`leadbay_my_lenses\` (or the \`lens://<base>/definition\` resource on hosts that expose one) before describing a new lens as workspace-wide, and say the scope is unverified if you cannot.
|
|
4565
4610
|
|
|
4566
4611
|
**Does not switch the active lens.** The new lens is created but the user stays on their current one. Offer \`leadbay_my_lenses(switchToLensId=<new id>)\` as a next step if they want to start pulling from it.
|
|
4567
4612
|
|
|
@@ -4710,7 +4755,7 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw).
|
|
|
4710
4755
|
|
|
4711
4756
|
Trigger phrases: "draft outreach for <Contact>", "write an email to <Contact>", "outreach package for <Company>".
|
|
4712
4757
|
|
|
4713
|
-
Do NOT use for: "research
|
|
4758
|
+
Do NOT use for: "research a lead, no draft" \u2192 \`leadbay_research_lead_by_id\`; "log sent outreach" \u2192 \`leadbay_report_outreach\`; "bulk enrich contacts" \u2192 \`leadbay_enrich_titles\`.
|
|
4714
4759
|
|
|
4715
4760
|
Prefer when: single picked lead/contact; action-imminent drafting context
|
|
4716
4761
|
|
|
@@ -4733,11 +4778,11 @@ email. Do NOT paste the email body into chat prose alongside.
|
|
|
4733
4778
|
|
|
4734
4779
|
---
|
|
4735
4780
|
|
|
4736
|
-
Prepare a single-lead outreach brief: the full \`lead\` block (score, \`split_ai_summary\`, \`location\`, \`size\`, \`phone_numbers\`, \`website\`, \`description\`, \`social_urls\`, \`social_presence\`), the \`recommended_contact\` in post-enrichment shape (\`contact_id\`, names, \`job_title\`, \`email\`, \`phone_number\`, \`linkedin_page\`, \`is_org_contact\` \u2014 nulls where not yet enriched), \`additional_contacts_count\`,
|
|
4781
|
+
Prepare a single-lead outreach brief: the full \`lead\` block (score, \`split_ai_summary\`, \`location\`, \`size\`, \`phone_numbers\`, \`website\`, \`description\`, \`social_urls\`, \`social_presence\`), the \`recommended_contact\` in post-enrichment shape (\`contact_id\`, names, \`job_title\`, \`email\`, \`phone_number\`, \`linkedin_page\`, \`is_org_contact\` \u2014 nulls where not yet enriched), \`additional_contacts_count\`, an \`enrichment\` block describing async state, the lead's \`qualification\` answers and web-research \`signals\`, and its \`history\` (\`notes\` on the lead and on each of its people, plus the \`activities\` timeline).
|
|
4737
4782
|
|
|
4738
4783
|
Optionally trigger contact enrichment in-flight with \`enrich:true\`. Enrichment is async (~60s). **Self-polling pattern (no separate tool needed):** re-call \`leadbay_prepare_outreach(leadId)\` without \`enrich\`; check \`enrichment.complete\`. When \`complete: true\`, the recommended contact now carries \`email\` and/or \`phone_number\`.
|
|
4739
4784
|
|
|
4740
|
-
|
|
4785
|
+
Each call marks the lead seen so it ages out of the Discover "new" view. That is not a prospecting action and never shows in \`history\`.
|
|
4741
4786
|
|
|
4742
4787
|
IRON LAW \u2014 OUTCOME AFTER OUTREACH. The moment the user reports outreach happened ("I sent it", "she didn't pick up", "left a voicemail", "they replied", a forwarded email thread, a calendar invite), you MUST (1) call leadbay_report_outreach with verification (gmail_message_id, calendar_event_id, or the user's literal one-sentence confirmation as user_confirmed.ref) AND (2) ask the user about the outcome and set epilogue_status to one of the 4 canonical values: EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED ("Meeting booked"), EPILOGUE_COULD_NOT_REACH_STILL_TRYING ("Trying to reach"), EPILOGUE_NOT_INTERESTED_LOST ("Not interested"), EPILOGUE_STILL_CHASING ("In progress"). Use the user-facing labels in dialogue ("What's the outcome \u2014 meeting booked, trying to reach, not interested, or in progress?"); never say "epilogue" out loud. Skipping this step silently de-ranks every future follow-up suggestion because pull_followups depends on honest, current outcomes.
|
|
4743
4788
|
|
|
@@ -4801,16 +4846,12 @@ Present as the richest single-record card the MCP emits. The user is seconds-to-
|
|
|
4801
4846
|
**H5: \u{1F3AF} Angles & approach**
|
|
4802
4847
|
|
|
4803
4848
|
- Render \`lead.split_ai_summary.approach_angle\` as the lead-in.
|
|
4804
|
-
- 3\u20134 bullets distilling \`split_ai_summary.next_step\` and
|
|
4849
|
+
- 3\u20134 bullets distilling \`split_ai_summary.next_step\`, \`signals\` and \`qualification\` into salesperson-voice talking points. Cite \`[source](url)\` inline when known.
|
|
4805
4850
|
- Final line: \`Recommended channel: <X> \u2014 <rationale>\`. Compute the recommendation from what data is available (email present \u2192 email; phone present \u2192 call; LinkedIn only \u2192 DM).
|
|
4806
4851
|
|
|
4807
|
-
**H5: \u{1F4DC} History with [
|
|
4808
|
-
|
|
4809
|
-
When prior contact-level actions / notes are surfaced (or when \`prospecting_actions_count > 0\`), render a reverse-chronological timeline: \`<date> \xB7 <action_type> \xB7 <one-line summary>\`. Quote-block recent notes below. If empty: \`*No prior touchpoints with this contact.*\`
|
|
4852
|
+
**H5: \u{1F4DC} History with [Company name]**
|
|
4810
4853
|
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
Same shape as the contact history, but only include items NOT duplicated from the contact section. If both empty: \`*No company-level history recorded.*\`
|
|
4854
|
+
From \`history\`, newest first: \`<date> \xB7 <activity type>\` per \`activities\` entry, then the \`notes\` quote-blocked, each prefixed with its \`contact\` when set. When a note or an \`EPILOGUE_*\` entry records a past contact, the draft follows up on it instead of opening cold. Both lists empty: \`*No prior touchpoints recorded.*\`
|
|
4814
4855
|
|
|
4815
4856
|
**H5: \u{1F465} Other contacts** (only if \`additional_contacts_count > 0\`)
|
|
4816
4857
|
|
|
@@ -4818,7 +4859,7 @@ One line: \`+N more contacts at this company \u2014 [see them all](leadbay_resea
|
|
|
4818
4859
|
|
|
4819
4860
|
**Closing line** (when enrichment is in progress): \`*Enrichment running \u2014 I'll refresh once email/phone lands.*\`
|
|
4820
4861
|
|
|
4821
|
-
**Hide:** \`id\`, \`lead.id\`, raw \`enrichment.hint\` when redundant with channel pills,
|
|
4862
|
+
**Hide:** \`id\`, \`lead.id\`, raw \`enrichment.hint\` when redundant with channel pills, any field whose value is the string \`"null"\`, deprecated \`other_contacts_count\` (use \`additional_contacts_count\`).
|
|
4822
4863
|
|
|
4823
4864
|
## Linking a contact's name
|
|
4824
4865
|
|
|
@@ -4868,12 +4909,11 @@ Offer 2\u20133 follow-ups. Choose based on enrichment state + available channels
|
|
|
4868
4909
|
| Observation | Suggest | Calls |
|
|
4869
4910
|
|-------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
|
|
4870
4911
|
| \`enrichment.triggered && !enrichment.complete\` | "Refresh now to check enrichment progress" | leadbay_prepare_outreach(leadId) \u2014 re-call |
|
|
4871
|
-
| Email available | "Draft the outreach email" | (agent self-drafts inline
|
|
4912
|
+
| Email available | "Draft the outreach email" | (agent self-drafts inline from signals + history) |
|
|
4872
4913
|
| Direct phone available | "Draft the 60-second call opener" | (agent self-drafts inline) |
|
|
4873
4914
|
| LinkedIn URL available | "Draft the LinkedIn DM" | (agent self-drafts inline) |
|
|
4874
4915
|
| Only company line, no direct phone | "Draft a switchboard script targeting [Contact]" | (agent self-drafts; flag uncertainty) |
|
|
4875
4916
|
| \`additional_contacts_count > 0\` | "Show me the other N contacts at this company" | leadbay_get_contacts(leadId) |
|
|
4876
|
-
| History is empty | "Pull the strategic overview before drafting" | leadbay_research_lead_by_id(leadId) |
|
|
4877
4917
|
| User reports they reached out | "Log this outreach \u2014 creates prospecting action + outcome" | leadbay_report_outreach(leadId, contact_id, ...) |
|
|
4878
4918
|
| User adds context for next time | "Save a note on the contact or company" | leadbay_add_note |
|
|
4879
4919
|
| After a successful exchange | "Update qualification answers based on what you learned" | leadbay_answer_clarification |
|
|
@@ -4923,14 +4963,15 @@ table. Detail + status priority below.
|
|
|
4923
4963
|
|
|
4924
4964
|
Pull KNOWN leads from the user's Monitor view \u2014 the re-engagement entry point.
|
|
4925
4965
|
|
|
4926
|
-
Backend: wraps \`GET /1.6/monitor
|
|
4966
|
+
Backend: wraps \`GET /1.6/monitor\` plus, when \`set_filter\` is supplied, a preceding \`POST /1.6/monitor/filter\`. The Monitor filter is a single \`FilterItem\` per user, server-persisted across sessions.
|
|
4927
4967
|
|
|
4928
|
-
**Filter mechanism \u2014 store-then-apply.** Pass \`set_filter: { criteria: FilterCriterion[] }\` to overwrite the server-stored filter, then the composite re-fetches with \`filtered:true\`. \`FilterCriterion\`
|
|
4968
|
+
**Filter mechanism \u2014 store-then-apply.** Pass \`set_filter: { criteria: FilterCriterion[] }\` to overwrite the server-stored filter, then the composite re-fetches with \`filtered:true\`. Every \`FilterCriterion\` MUST carry a \`type\` discriminator \u2014 \`{sector_ids: [...]}\` is silently dropped, \`{type: "sector_ids", sectors: [...]}\` is not. Types: \`size\`, \`keywords\`, \`sector_ids\`, \`location_ids\`, \`custom_field\`(\`_comparison\`), \`yc\`, \`liked\`, \`last_action\`, \`last_action_date\`.
|
|
4929
4969
|
|
|
4930
4970
|
Practical mapping from user phrasing to criterion:
|
|
4931
4971
|
|
|
4932
4972
|
| User phrase | Criterion |
|
|
4933
4973
|
|---|---|
|
|
4974
|
+
| "supermarket leads" | \`{type: "sector_ids", sectors: ["5134"]}\` |
|
|
4934
4975
|
| "leads in Lyon" | \`{type: "location_ids", locations: [<admin_area_id>]}\` |
|
|
4935
4976
|
| "healthcare staffing" | \`{type: "keywords", keywords: ["healthcare", "staffing"]}\` |
|
|
4936
4977
|
| "leads I haven't touched in 30 days" | \`{type: "last_action_date", last_days: 30}\` |
|
|
@@ -4938,7 +4979,7 @@ Practical mapping from user phrasing to criterion:
|
|
|
4938
4979
|
| "leads 50\u2013200 employees" | \`{type: "size", sizes: [{min: 50, max: 200}]}\` |
|
|
4939
4980
|
| "Y Combinator companies" | \`{type: "yc"}\` |
|
|
4940
4981
|
|
|
4941
|
-
Geo filtering needs \`admin_area_id\` resolution \u2014
|
|
4982
|
+
Geo filtering needs \`admin_area_id\` resolution \u2014 \`location_ids\` rejects free text. Pass \`city\` and the composite resolves it via \`/geo/search\`. Ambiguous matches return \`status: "ambiguous_locations"\` \u2014 pick an id and re-call with \`city_id\`.
|
|
4942
4983
|
|
|
4943
4984
|
In \`keywords\` a place name is a TEXT-MATCH on company descriptions (\u22480 hits), not a filter \u2014 never fall back to it, nor to the unfiltered view, when a place is ambiguous.
|
|
4944
4985
|
|
|
@@ -5087,6 +5128,8 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
|
|
|
5087
5128
|
| Observation | Suggest | Calls |
|
|
5088
5129
|
|---|---|---|
|
|
5089
5130
|
| Always (top of menu) | "Prep outreach for [top row's contact]" | leadbay_prepare_outreach(leadId) |
|
|
5131
|
+
| \u2265 1 lead returned | "Build an interactive call board" | leadbay_artifact_kit \u2192 its CANONICAL board recipe, data in hand |
|
|
5132
|
+
| "how well do we cover sector X / city Y" | "Build a coverage board" | leadbay_artifact_kit \u2192 its COVERAGE recipe (\`lb.portfolioSectors\` + \`lb.segmentCount\`) |
|
|
5090
5133
|
| User named a city / sector / timeframe | "Refilter by [their phrase]" | leadbay_pull_followups(set_filter: { criteria: [...] }) |
|
|
5091
5134
|
| \`pagination.has_more == true\` | "Pull the next page" | leadbay_pull_followups(page = current + 1) |
|
|
5092
5135
|
| \u22653 rows \u2728 (never-touched) | "Surface only never-touched leads" | set_filter with \`last_action_date.last_days = 0\` |
|
|
@@ -5097,7 +5140,7 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
|
|
|
5097
5140
|
| User wants to defer a lead | "Snooze [Company] for 3 / 6 / 12 months" | leadbay_set_pushback({ lead_ids:[leadId], status:"3" }) |
|
|
5098
5141
|
| User completed outreach mid-flow | "Log the outreach + record the outcome" | leadbay_report_outreach |
|
|
5099
5142
|
| Discovery mode might fit better | "Looking for NEW leads instead? Switch to discovery." | leadbay_pull_leads |
|
|
5100
|
-
Always offer at least one of: prep outreach, refilter, pushback. Pushback is the canonical way to honor "not now" / "next quarter"
|
|
5143
|
+
Always offer at least one of: prep outreach, refilter, pushback. Pushback is the canonical way to honor "not now" / "next quarter".
|
|
5101
5144
|
`;
|
|
5102
5145
|
leadbay_pull_leads = `## WHEN TO USE
|
|
5103
5146
|
|
|
@@ -5134,7 +5177,7 @@ contact + title. Full algorithm + linking rules below.
|
|
|
5134
5177
|
|
|
5135
5178
|
Pull up new leads from the user's last-active lens \u2014 the canonical "show me today's prospects" tool.
|
|
5136
5179
|
|
|
5137
|
-
Leadbay works like an inbox: each time the user logs back in, a fresh batch is delivered, paced by how many leads they've actually acted on recently. Pulling more won't produce more; user outreach/skips/saves does. Each returned lead carries a one-line \`qualification_summary\` built from leadbay_ai_agent_responses, plus the rich tags / scores / engagement counters / in-flight flags from the lead summary.
|
|
5180
|
+
Leadbay works like an inbox: each time the user logs back in, a fresh batch is delivered, paced by how many leads they've actually acted on recently. Pulling more won't produce more; user outreach/skips/saves does. Leads liked, disliked, noted, with a status or in a campaign are left out. Each returned lead carries a one-line \`qualification_summary\` built from leadbay_ai_agent_responses, plus the rich tags / scores / engagement counters / in-flight flags from the lead summary.
|
|
5138
5181
|
|
|
5139
5182
|
Roughly the top 10 of the batch come pre-qualified (populated qualification_summary + ai_agent_lead_score); leads below the top ~10 carry only the basic firmographic \`score\` \u2014 not worse, just resource-saved by the system. Call leadbay_bulk_qualify_leads to deepen any of them on demand \u2014 a healthy daily rhythm is to bulk-qualify the rows without \u2756 caps so tomorrow's top-10 list is richer.
|
|
5140
5183
|
|
|
@@ -5251,7 +5294,7 @@ Pick 2\u20133 items below based on what was actually observed in the response. T
|
|
|
5251
5294
|
|
|
5252
5295
|
| Observation | Suggest | Calls |
|
|
5253
5296
|
|------------------------------------------------------------|--------------------------------------------------------------|--------------------------------------------------------|
|
|
5254
|
-
| \u2265
|
|
5297
|
+
| \u2265 1 lead returned \u2014 offer FIRST | "Build an interactive lead triage board" | leadbay_artifact_kit \u2192 its CANONICAL triage-board recipe, data in hand (do NOT re-call pull_leads) |
|
|
5255
5298
|
| \u2265 1 lead returned (any batch) | "Enrich top leads" (reveal decision-maker email/phone on the top leads) | leadbay_enrich_titles({ leadIds: shown leads[].id, lensId }) \u2014 scope to the leads JUST shown; OMIT \`titles\` so it runs the no-spend discovery preview. Confirm titles + channels, then re-call with titles + confirm to launch |
|
|
5256
5299
|
| \`has_more == true\` | "Pull the next page (page N+1 of M)" | leadbay_pull_leads(page = current + 1, lensId = pinned)|
|
|
5257
5300
|
| \u2265 3 rows have \`qualification_summary.answered == 0\` | "Deepen AI qualification on the rows without \u2756 caps" | leadbay_bulk_qualify_leads(leadIds=[\u2026]) |
|
|
@@ -5663,15 +5706,16 @@ already covered without seeing them.
|
|
|
5663
5706
|
|---|---|
|
|
5664
5707
|
| A sector, a headcount band, a territory | \`leadbay_adjust_audience\` / \`leadbay_new_lens\` filters \u2014 never a question |
|
|
5665
5708
|
| A company trait a stranger could estimate from that company's own website or registry record \u2014 "runs its own maintenance crew", "operates a large vehicle fleet", "is legally active and not in liquidation" | a qualification question |
|
|
5709
|
+
| A kind of company that is never the buyer \u2014 "consulting firms", "franchise locations of national chains", "subsidiaries of large listed groups" | a negative criterion of the ideal buyer profile, \`leadbay_set_qualification_questions({add_anti_patterns})\`. It uses no question slot, and qualification reads it as a negative signal |
|
|
5666
5710
|
| A qualitative orientation too broad for one yes/no \u2014 "we sell to the private sector, not the public one", "harden the exclusion on the business model" | the targeting prompt, \`leadbay_refine_prompt\` |
|
|
5667
|
-
| Named companies \u2014 "exclude Groupe Solidum, Dentego" | \`leadbay_dislike_lead\` / \`leadbay_set_lead_status\` on those leads. A question must NEVER name a company |
|
|
5711
|
+
| Named companies \u2014 "exclude Groupe Solidum, Dentego" | \`leadbay_dislike_lead\` with the user's words as \`reason\` / \`leadbay_set_lead_status\` on those leads. A status stores no reason, so add the user's reason with \`leadbay_add_note\`. A question must NEVER name a company |
|
|
5668
5712
|
| CRM state \u2014 "already contacted", "already in a campaign", "already excluded" | read it: \`leadbay_pull_followups\`, \`leadbay_list_campaigns\`. A question cannot observe your own history |
|
|
5669
5713
|
| A delivery requirement \u2014 "email AND phone mandatory", "only score 54\u201395" | enrichment plus your own post-filter of the result. A question scores the COMPANY; it cannot see whether Leadbay holds a phone number for a contact |
|
|
5670
5714
|
| An event or purchase trigger \u2014 "currently hiring an SDR", "just opened a site" | a qualification question or the targeting prompt. NEVER an \`example_lead\` description or a \`query\`: those match stable registry text, which never mentions events |
|
|
5671
5715
|
|
|
5672
5716
|
**3 \u2014 Decide whether to change anything at all.** Touching a question
|
|
5673
5717
|
re-scores every lead in the pipeline and draws on the org's quota, so a change
|
|
5674
|
-
that surfaces the same companies is a pure loss.
|
|
5718
|
+
that surfaces the same companies is a pure loss. Five reasons to write NOTHING
|
|
5675
5719
|
and say why:
|
|
5676
5720
|
|
|
5677
5721
|
1. **An existing question already covers the rule.** Quote that question back
|
|
@@ -5686,6 +5730,11 @@ and say why:
|
|
|
5686
5730
|
4. **The question the user asked for is not decisive.** See step 4: say so,
|
|
5687
5731
|
offer the sharper version, and write only what they then choose. Adding a
|
|
5688
5732
|
question you know separates nothing is worse than adding none.
|
|
5733
|
+
5. **Qualification already rejects the lead the user turned down.** A negative
|
|
5734
|
+
\`ai_score\` on \`leadbay_dislike_lead\`, or a negative \`ai_agent_lead_score\` in
|
|
5735
|
+
\`leadbay_research_lead_by_id\`, means the current questions and anti-patterns
|
|
5736
|
+
already score it against. An existing \`anti_patterns\` entry that says the same
|
|
5737
|
+
thing counts too.
|
|
5689
5738
|
|
|
5690
5739
|
**The ceiling is 5 questions.** Read the count before you answer an "add a
|
|
5691
5740
|
question" request: at 5 the honest answer is not "sure, I'll add it". Say in
|
|
@@ -5935,13 +5984,22 @@ to actually trying again or pivoting, not the end of the conversation.
|
|
|
5935
5984
|
`;
|
|
5936
5985
|
leadbay_report_outreach = `Log an outreach action (email, call, message, meeting) on a lead so the human team using Leadbay sees the progress in their UI. Writes a NOTE on the lead and (optionally) sets an EPILOGUE status (still chasing, meeting booked, etc.). Bulk variant: pass \`lead_ids=[uuid,...]\` instead of \`lead_id\` (epilogue is bulk-native; notes fan out per-lead).
|
|
5937
5986
|
|
|
5938
|
-
VERIFICATION REQUIRED: every call must include \`verification={source: 'gmail_message_id'|'calendar_event_id'|'user_confirmed', ref: '<id-or-confirmation>'}\` to prevent hallucinated outreach poisoning the pipeline. The verification is appended to the note body. Skipping or fabricating verification poisons the human team's pipeline.
|
|
5987
|
+
VERIFICATION REQUIRED: every call must include \`verification={source: 'gmail_message_id'|'calendar_event_id'|'user_confirmed', ref: '<id-or-confirmation>'}\` to prevent hallucinated outreach poisoning the pipeline. The verification is appended to the note body. Skipping or fabricating verification poisons the human team's pipeline. For an email, pass the Gmail message id of the sent message whenever you have it: \`leadbay_pull_followups\` hands it back so the contact's reply can be found in that thread.
|
|
5939
5988
|
|
|
5940
5989
|
WHEN TO USE: AFTER actually emailing/calling/meeting/messaging a contact, OR after a substantive decision the user wants logged (skip, save, hand off).
|
|
5941
5990
|
|
|
5942
5991
|
WHEN NOT TO USE: BEFORE doing the outreach (use \`dry_run:true\` to validate args first); without verification (call will be rejected); from a flow where the user did not consent to having actions logged automatically.
|
|
5943
5992
|
|
|
5993
|
+
To log on the person the outreach went to, pass their org contact id as \`contact_id\` with a single \`lead_id\`. The note and the status then go on that person, and the lead's Monitor row still shows the action and the status. Logging the same Gmail message id or calendar event id twice writes nothing the second time: the lead or person comes back in \`already_logged\`.
|
|
5994
|
+
|
|
5944
5995
|
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\`.
|
|
5996
|
+
|
|
5997
|
+
|
|
5998
|
+
### Logging outreach from the user's mailbox and calendar
|
|
5999
|
+
|
|
6000
|
+
The \`leadbay_sync_outreach\` prompt runs the block below once, over the last 14 days instead of 2. It then schedules a daily task named "Leadbay outreach sync" whose instruction is the block, word for word. The block is the whole procedure, because a scheduled run sees nothing else.
|
|
6001
|
+
|
|
6002
|
+
> Leadbay outreach sync. Read my mailbox and calendar for the last 2 days: the emails I sent, the emails I received, and the meetings with outside guests. Never send, reply to, archive or label anything. Skip newsletters, no-reply senders, my own company's domain, and personal addresses such as gmail.com or orange.fr. For each other person, call leadbay_research_lead_by_name_fuzzy with only their full name as companyName, from the display name or from the address ("marcel.frei@" is Marcel Frei). This finds them on my own leads even when the company's website is another domain. If that finds no lead, or no contact on it with that exact email, call it again with the email's domain as companyName and website and the address as email. If there is still no lead, skip them and never import the company. Log each email and meeting with leadbay_report_outreach: the lead_id, the id of the contact with that email as contact_id, a one-line note ("Email sent: <subject>", "Replied: <what they said>" or "Meeting: <title>, <date>"), and verification {source: gmail_message_id, ref: the message id} or {source: calendar_event_id, ref: the event id}. Without a contact at that email, leave contact_id out. Set epilogue_status only on plain evidence: a meeting is INTEREST_VALIDATED_OR_MEETING_PLANED, a reply that declines is NOT_INTERESTED_LOST, a bounce is COULD_NOT_REACH_STILL_TRYING, and an email sent with no reply yet is STILL_CHASING. Log any other reply with no status and list it for me. A message or meeting already logged comes back in already_logged with nothing written. A CRM activity, or mail from a mailbox other than Gmail, has no id leadbay_report_outreach accepts as proof: list it, do not log it. End with a table of what you logged (lead, person, what, date, status) and what you skipped and why.
|
|
5945
6003
|
`;
|
|
5946
6004
|
leadbay_research_lead_by_id = `## WHEN TO USE
|
|
5947
6005
|
|
|
@@ -6137,7 +6195,7 @@ out?"\`
|
|
|
6137
6195
|
|
|
6138
6196
|
| Observation | Suggest | Calls |
|
|
6139
6197
|
|--------------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------|
|
|
6140
|
-
| Lead is clearly not a fit (wrong industry, too small) | "Dislike this lead" | leadbay_dislike_lead({
|
|
6198
|
+
| Lead is clearly not a fit (wrong industry, too small) | "Dislike this lead" | leadbay_dislike_lead({ lead_id, reason }) |
|
|
6141
6199
|
| User is done with this lead | "Back to the inbox" | leadbay_pull_leads |
|
|
6142
6200
|
`;
|
|
6143
6201
|
leadbay_research_lead_by_name_fuzzy = `## WHEN TO USE
|
|
@@ -6308,7 +6366,7 @@ out?"\`
|
|
|
6308
6366
|
|
|
6309
6367
|
| Observation | Suggest | Calls |
|
|
6310
6368
|
|--------------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------|
|
|
6311
|
-
| Lead is clearly not a fit (wrong industry, too small) | "Dislike this lead" | leadbay_dislike_lead({
|
|
6369
|
+
| Lead is clearly not a fit (wrong industry, too small) | "Dislike this lead" | leadbay_dislike_lead({ lead_id, reason }) |
|
|
6312
6370
|
| User is done with this lead | "Back to the inbox" | leadbay_pull_leads |
|
|
6313
6371
|
|
|
6314
6372
|
|
|
@@ -6836,6 +6894,8 @@ Leadbay allows **at most 5** qualification questions. If a change would exceed 5
|
|
|
6836
6894
|
|
|
6837
6895
|
Returns the resulting \`{qualification_questions, count, previous_count, changed}\`.
|
|
6838
6896
|
|
|
6897
|
+
**Negative criteria \u2014 \`add_anti_patterns\`.** The ideal buyer profile's \`anti_patterns\` list kinds of company that are never the buyer. Qualification reads them as negative signals when it scores a lead, beside the questions. \`add_anti_patterns\` appends to that list and returns it as \`anti_patterns\`. It uses no question slot and cannot be combined with \`questions\`/\`add\`/\`remove\`. It is an org-admin action and draws on the org's AI quota. After it, Leadbay regenerates its targeting in the background: new example buyers, a refresh of every lens, and, when nobody has written the questions by hand, possibly new questions. Leads already scored keep their score until they are next qualified. Leadbay also stops rewriting the profile by itself. Tell the user this when you propose it.
|
|
6898
|
+
|
|
6839
6899
|
### A stated fit rule is a SETTING \u2014 read, decide, propose, then write
|
|
6840
6900
|
|
|
6841
6901
|
**Never answer a stated rule from memory.** "C'est not\xE9", "already applied",
|
|
@@ -6864,15 +6924,16 @@ already covered without seeing them.
|
|
|
6864
6924
|
|---|---|
|
|
6865
6925
|
| A sector, a headcount band, a territory | \`leadbay_adjust_audience\` / \`leadbay_new_lens\` filters \u2014 never a question |
|
|
6866
6926
|
| A company trait a stranger could estimate from that company's own website or registry record \u2014 "runs its own maintenance crew", "operates a large vehicle fleet", "is legally active and not in liquidation" | a qualification question |
|
|
6927
|
+
| A kind of company that is never the buyer \u2014 "consulting firms", "franchise locations of national chains", "subsidiaries of large listed groups" | a negative criterion of the ideal buyer profile, \`leadbay_set_qualification_questions({add_anti_patterns})\`. It uses no question slot, and qualification reads it as a negative signal |
|
|
6867
6928
|
| A qualitative orientation too broad for one yes/no \u2014 "we sell to the private sector, not the public one", "harden the exclusion on the business model" | the targeting prompt, \`leadbay_refine_prompt\` |
|
|
6868
|
-
| Named companies \u2014 "exclude Groupe Solidum, Dentego" | \`leadbay_dislike_lead\` / \`leadbay_set_lead_status\` on those leads. A question must NEVER name a company |
|
|
6929
|
+
| Named companies \u2014 "exclude Groupe Solidum, Dentego" | \`leadbay_dislike_lead\` with the user's words as \`reason\` / \`leadbay_set_lead_status\` on those leads. A status stores no reason, so add the user's reason with \`leadbay_add_note\`. A question must NEVER name a company |
|
|
6869
6930
|
| CRM state \u2014 "already contacted", "already in a campaign", "already excluded" | read it: \`leadbay_pull_followups\`, \`leadbay_list_campaigns\`. A question cannot observe your own history |
|
|
6870
6931
|
| A delivery requirement \u2014 "email AND phone mandatory", "only score 54\u201395" | enrichment plus your own post-filter of the result. A question scores the COMPANY; it cannot see whether Leadbay holds a phone number for a contact |
|
|
6871
6932
|
| An event or purchase trigger \u2014 "currently hiring an SDR", "just opened a site" | a qualification question or the targeting prompt. NEVER an \`example_lead\` description or a \`query\`: those match stable registry text, which never mentions events |
|
|
6872
6933
|
|
|
6873
6934
|
**3 \u2014 Decide whether to change anything at all.** Touching a question
|
|
6874
6935
|
re-scores every lead in the pipeline and draws on the org's quota, so a change
|
|
6875
|
-
that surfaces the same companies is a pure loss.
|
|
6936
|
+
that surfaces the same companies is a pure loss. Five reasons to write NOTHING
|
|
6876
6937
|
and say why:
|
|
6877
6938
|
|
|
6878
6939
|
1. **An existing question already covers the rule.** Quote that question back
|
|
@@ -6887,6 +6948,11 @@ and say why:
|
|
|
6887
6948
|
4. **The question the user asked for is not decisive.** See step 4: say so,
|
|
6888
6949
|
offer the sharper version, and write only what they then choose. Adding a
|
|
6889
6950
|
question you know separates nothing is worse than adding none.
|
|
6951
|
+
5. **Qualification already rejects the lead the user turned down.** A negative
|
|
6952
|
+
\`ai_score\` on \`leadbay_dislike_lead\`, or a negative \`ai_agent_lead_score\` in
|
|
6953
|
+
\`leadbay_research_lead_by_id\`, means the current questions and anti-patterns
|
|
6954
|
+
already score it against. An existing \`anti_patterns\` entry that says the same
|
|
6955
|
+
thing counts too.
|
|
6890
6956
|
|
|
6891
6957
|
**The ceiling is 5 questions.** Read the count before you answer an "add a
|
|
6892
6958
|
question" request: at 5 the honest answer is not "sure, I'll add it". Say in
|
|
@@ -6973,7 +7039,7 @@ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We
|
|
|
6973
7039
|
|
|
6974
7040
|
### RENDERING
|
|
6975
7041
|
|
|
6976
|
-
After a change, confirm in one line \u2014 e.g. **"Added 1 question \u2014 you now score leads against 4 questions."** or **"Removed 'the flooring question' \u2014 3 questions remain."** Then list the resulting questions as a numbered list. When the result is a non-changing preview (a removal awaiting confirmation), surface the \`hint\` (what would be removed) and ask the user to confirm \u2014 do NOT auto-confirm.
|
|
7042
|
+
After a change, confirm in one line \u2014 e.g. **"Added 1 question \u2014 you now score leads against 4 questions."** or **"Removed 'the flooring question' \u2014 3 questions remain."** Then list the resulting questions as a numbered list. When the result is a non-changing preview (a removal awaiting confirmation), surface the \`hint\` (what would be removed) and ask the user to confirm \u2014 do NOT auto-confirm. After \`add_anti_patterns\`, confirm in one line \u2014 e.g. **"Added 1 negative criterion to your buyer profile."** \u2014 then list \`anti_patterns\`.
|
|
6977
7043
|
`;
|
|
6978
7044
|
leadbay_set_telemetry = `## WHEN TO USE
|
|
6979
7045
|
|
|
@@ -7420,13 +7486,14 @@ Examples that should NOT invoke this tool (sound similar, route elsewhere):
|
|
|
7420
7486
|
|
|
7421
7487
|
## RENDER (quick)
|
|
7422
7488
|
|
|
7423
|
-
Report user + org
|
|
7424
|
-
|
|
7489
|
+
Report user + org + one line on what the account targets
|
|
7490
|
+
(\`search_configuration\`). Show quota only when asked (quota/account status)
|
|
7491
|
+
or a window is exhausted. NEVER mention the lens unless asked (use
|
|
7425
7492
|
\`last_requested_lens_name\`, never the id). SILENT on quota when
|
|
7426
7493
|
\`quota_error\` set, \`unlimited_credits\` true, or quota null. Else render
|
|
7427
|
-
Daily/Weekly/Monthly from \`quota.user\` (
|
|
7428
|
-
|
|
7429
|
-
|
|
7494
|
+
Daily/Weekly/Monthly from \`quota.user\` (else \`quota.org\`) as
|
|
7495
|
+
\`$used / $cap (N% used) \xB7 resets\` (resource-count table if \`spend[]\`
|
|
7496
|
+
empty). Never say raw "credits".
|
|
7430
7497
|
|
|
7431
7498
|
---
|
|
7432
7499
|
|
|
@@ -7434,6 +7501,8 @@ Show the user's account state \u2014 admin rights, language, last-active lens, q
|
|
|
7434
7501
|
|
|
7435
7502
|
**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.**
|
|
7436
7503
|
|
|
7504
|
+
**\`search_configuration\`** is what this account is set up to find: the ideal buyer profile, the targeting prompt, the qualification questions and the buying signals Leadbay watches for. Even when the user only asked whether Leadbay is connected, it is how you tell them what Leadbay is looking for on their behalf. A null field is unset or unreadable: never tell the user it is missing.
|
|
7505
|
+
|
|
7437
7506
|
**\`mcp_version\`** is the version of the Leadbay MCP server answering the call. When the user asks which Leadbay version they are running, answer with it.
|
|
7438
7507
|
|
|
7439
7508
|
**\`notifications\` block.** The response now includes a top-level \`notifications\` array listing background work the user (or agent) initiated that has since completed (\`bulk_enrich\`, \`bulk_qualify\`, \`import\`). These are signals to revise prior agent outputs the just-finished work might have made stale \u2014 they're NOT a pending-task list for the user. After revising (or confirming nothing is affected), call \`leadbay_acknowledge_notification(notification_id)\`. Full handling protocol below.
|
|
@@ -7790,16 +7859,17 @@ User picks \u2192 call the matching \`Calls\` tool. Constraints: 2\u20134 mutual
|
|
|
7790
7859
|
|
|
7791
7860
|
|
|
7792
7861
|
|
|
7793
|
-
Pick the 2-3
|
|
7862
|
+
Pick the 2-3 that match what happened, never the whole table:
|
|
7794
7863
|
|
|
7795
7864
|
| Observation | Suggest | Calls |
|
|
7796
7865
|
|---|---|---|
|
|
7866
|
+
| \u2265 1 delivered \u2014 offer FIRST | "Build an interactive lead triage board" | leadbay_artifact_kit \u2192 CANONICAL recipe, data in hand |
|
|
7797
7867
|
| Job still running (\`still_running: true\`) | "Check on it in ~1 min" | leadbay_lead_job_status(job_id, wait_seconds: 45) |
|
|
7798
7868
|
| 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}) |
|
|
7799
7869
|
| Delivered leads look right | "Draft outreach for the top ones" | leadbay_prepare_outreach |
|
|
7800
7870
|
| Delivered 0 or off-profile | "Reshape the example and retry" (name the fix from funnel + scope_notes) | leadbay_find_new_leads (NEW request_id) |
|
|
7801
7871
|
| 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) |
|
|
7802
|
-
| Stopped on org quota (\`stop_reason: quota\`) | "Check which window is exhausted and when it resets" \u2014 never a re-run: it cannot clear
|
|
7872
|
+
| Stopped on org quota (\`stop_reason: quota\`) | "Check which window is exhausted and when it resets" \u2014 never a re-run: it cannot clear a quota and burns a submit slot to stop in the same place | leadbay_account_status |
|
|
7803
7873
|
| User wants these tracked in Leadbay | "Add the keepers to a campaign" | leadbay_create_campaign / leadbay_add_leads_to_campaign |
|
|
7804
7874
|
`,
|
|
7805
7875
|
leadbay_scan_portfolio_signals: `## WHEN TO USE
|
|
@@ -10451,7 +10521,7 @@ var init_get_enrichment_job_titles = __esm({
|
|
|
10451
10521
|
});
|
|
10452
10522
|
|
|
10453
10523
|
// ../core/dist/composite/_qualify-helpers.js
|
|
10454
|
-
import { createHash } from "crypto";
|
|
10524
|
+
import { createHash as createHash2 } from "crypto";
|
|
10455
10525
|
function buildQuestionOrder(questions) {
|
|
10456
10526
|
const out = /* @__PURE__ */ new Map();
|
|
10457
10527
|
questions.forEach((q, i) => {
|
|
@@ -10737,7 +10807,7 @@ function extractHintsAndCandidates(fileImport, catalog) {
|
|
|
10737
10807
|
function fingerprintMapping(mapping) {
|
|
10738
10808
|
const sorted = Object.keys(mapping).sort();
|
|
10739
10809
|
const flat = sorted.map((k) => `${k}=${mapping[k]}`).join("|");
|
|
10740
|
-
return
|
|
10810
|
+
return createHash2("sha256").update(flat).digest("hex").slice(0, 32);
|
|
10741
10811
|
}
|
|
10742
10812
|
async function refreshLeadStates(client, leadIds, questionOrder) {
|
|
10743
10813
|
return Promise.all(leadIds.map(async (leadId) => {
|
|
@@ -10805,10 +10875,10 @@ var init_qualify_helpers = __esm({
|
|
|
10805
10875
|
});
|
|
10806
10876
|
|
|
10807
10877
|
// ../core/dist/jobs/launch-guard.js
|
|
10808
|
-
import { createHash as
|
|
10878
|
+
import { createHash as createHash3 } from "crypto";
|
|
10809
10879
|
function launchFingerprint(parts) {
|
|
10810
10880
|
const flat = parts.map((p) => Array.isArray(p) ? [...p].sort().join(",") : String(p)).join("|");
|
|
10811
|
-
return
|
|
10881
|
+
return createHash3("sha256").update(flat).digest("hex");
|
|
10812
10882
|
}
|
|
10813
10883
|
function sweep(now) {
|
|
10814
10884
|
for (const [k, v] of recent) {
|
|
@@ -11100,7 +11170,7 @@ var init_import_commit_log = __esm({
|
|
|
11100
11170
|
});
|
|
11101
11171
|
|
|
11102
11172
|
// ../core/dist/composite/import-leads.js
|
|
11103
|
-
import { createHash as
|
|
11173
|
+
import { createHash as createHash4, randomUUID } from "crypto";
|
|
11104
11174
|
function isImportLeadsRunningResult(result) {
|
|
11105
11175
|
return "status" in result && result.status === "running";
|
|
11106
11176
|
}
|
|
@@ -11162,7 +11232,7 @@ function importFingerprint(params, prep) {
|
|
|
11162
11232
|
mappings: prep.mappings,
|
|
11163
11233
|
dry_run: Boolean(params.dry_run)
|
|
11164
11234
|
};
|
|
11165
|
-
return
|
|
11235
|
+
return createHash4("sha256").update(stableStringify(payload)).digest("hex");
|
|
11166
11236
|
}
|
|
11167
11237
|
function checkAborted(signal) {
|
|
11168
11238
|
if (signal?.aborted) {
|
|
@@ -13730,11 +13800,21 @@ var init_like_lead = __esm({
|
|
|
13730
13800
|
});
|
|
13731
13801
|
|
|
13732
13802
|
// ../core/dist/tools/dislike-lead.js
|
|
13733
|
-
|
|
13803
|
+
function messageOf(e) {
|
|
13804
|
+
if (e && typeof e === "object" && "message" in e)
|
|
13805
|
+
return String(e.message);
|
|
13806
|
+
return String(e);
|
|
13807
|
+
}
|
|
13808
|
+
var NOTE_MAX, noteText, TARGETING, alreadyRejected, targetingFor, dislikeLead;
|
|
13734
13809
|
var init_dislike_lead = __esm({
|
|
13735
13810
|
"../core/dist/tools/dislike-lead.js"() {
|
|
13736
13811
|
"use strict";
|
|
13737
13812
|
init_tool_descriptions_generated();
|
|
13813
|
+
NOTE_MAX = 4095;
|
|
13814
|
+
noteText = (reason) => `Disliked: ${reason}`.slice(0, NOTE_MAX);
|
|
13815
|
+
TARGETING = "Unchanged. This dislike hides one lead from this user. Leadbay itself only avoids a sector on the lens's next refresh after two of the lens's leads in it are disliked and none liked, and a city after two. If the reason describes a kind of company rather than this one company (a sector, a size, a territory, consultancies, subsidiaries of large groups), check whether the org's criteria already reject it. An `ai_score` below 0 means qualification already scored this lead against them: nothing to change. Otherwise call leadbay_get_qualification_questions: if no anti-pattern or question already says it, propose adding the reason as a negative criterion with leadbay_set_qualification_questions({add_anti_patterns}), and write it only on the user's yes. In a run with no user, put the proposal in your report. Several dislikes with one reason are one rule.";
|
|
13816
|
+
alreadyRejected = (score) => `Unchanged, and nothing to change: qualification already scores this lead at ${score}, against the org's current criteria. The dislike and its reason are enough. Do not propose a setting change for this reason.`;
|
|
13817
|
+
targetingFor = (score) => typeof score.ai_score === "number" && score.ai_score < 0 ? alreadyRejected(score.ai_score) : TARGETING;
|
|
13738
13818
|
dislikeLead = {
|
|
13739
13819
|
name: "leadbay_dislike_lead",
|
|
13740
13820
|
annotations: {
|
|
@@ -13753,6 +13833,10 @@ var init_dislike_lead = __esm({
|
|
|
13753
13833
|
lead_id: {
|
|
13754
13834
|
type: "string",
|
|
13755
13835
|
description: "UUID of the lead to dislike."
|
|
13836
|
+
},
|
|
13837
|
+
reason: {
|
|
13838
|
+
type: "string",
|
|
13839
|
+
description: `Why the lead is rejected, in the user's own words ("soci\xE9t\xE9 de conseil, hors ICP", "unpaid invoice with them"). In a run with no user, the criterion you applied. Saved as a note on the lead that the whole team sees. Omit when no reason was given; never invent one.`
|
|
13756
13840
|
}
|
|
13757
13841
|
},
|
|
13758
13842
|
required: ["lead_id"],
|
|
@@ -13760,14 +13844,36 @@ var init_dislike_lead = __esm({
|
|
|
13760
13844
|
},
|
|
13761
13845
|
execute: async (client, params) => {
|
|
13762
13846
|
await client.requestVoid("POST", `/leads/${params.lead_id}/dislike`);
|
|
13763
|
-
|
|
13847
|
+
const result = { applied: true, lead_id: params.lead_id, action: "disliked" };
|
|
13848
|
+
const reason = params.reason?.trim();
|
|
13849
|
+
if (!reason)
|
|
13850
|
+
return { ...result, targeting: TARGETING };
|
|
13851
|
+
const aiScore = client.resolveDefaultLens().then((lensId) => client.request("GET", `/lenses/${lensId}/leads/${params.lead_id}/with_or_without_lens`)).then((lead) => ({ ai_score: lead?.ai_agent_lead_score ?? null })).catch(() => ({}));
|
|
13852
|
+
const text3 = noteText(reason);
|
|
13853
|
+
try {
|
|
13854
|
+
const notes = await client.request("GET", `/leads/${params.lead_id}/notes`);
|
|
13855
|
+
const existing = Array.isArray(notes) ? notes.find((n) => n.note === text3) : void 0;
|
|
13856
|
+
const note = existing ?? await client.request("POST", `/leads/${params.lead_id}/notes`, { note: text3 });
|
|
13857
|
+
const score = await aiScore;
|
|
13858
|
+
return { ...result, reason_saved: true, note_id: note.id, ...score, targeting: targetingFor(score) };
|
|
13859
|
+
} catch (e) {
|
|
13860
|
+
const score = await aiScore;
|
|
13861
|
+
return {
|
|
13862
|
+
...result,
|
|
13863
|
+
reason_saved: false,
|
|
13864
|
+
reason_error: messageOf(e),
|
|
13865
|
+
hint: "The dislike is recorded but the reason is not. Call leadbay_dislike_lead again with the same lead_id and reason to save it; a note already saved is not written twice.",
|
|
13866
|
+
...score,
|
|
13867
|
+
targeting: targetingFor(score)
|
|
13868
|
+
};
|
|
13869
|
+
}
|
|
13764
13870
|
}
|
|
13765
13871
|
};
|
|
13766
13872
|
}
|
|
13767
13873
|
});
|
|
13768
13874
|
|
|
13769
13875
|
// ../core/dist/tools/set-lead-status.js
|
|
13770
|
-
function
|
|
13876
|
+
function messageOf2(e) {
|
|
13771
13877
|
if (e && typeof e === "object" && "message" in e)
|
|
13772
13878
|
return String(e.message);
|
|
13773
13879
|
return String(e);
|
|
@@ -13787,7 +13893,7 @@ async function writeAll(client, leadIds, status, statusDate) {
|
|
|
13787
13893
|
await client.requestVoid("POST", statusDatePath(id), statusDateBody(statusDate));
|
|
13788
13894
|
}
|
|
13789
13895
|
} catch (e) {
|
|
13790
|
-
failed.push({ lead_id: id, message:
|
|
13896
|
+
failed.push({ lead_id: id, message: messageOf2(e) });
|
|
13791
13897
|
}
|
|
13792
13898
|
}
|
|
13793
13899
|
}
|
|
@@ -14263,6 +14369,46 @@ var init_update_contact = __esm({
|
|
|
14263
14369
|
}
|
|
14264
14370
|
});
|
|
14265
14371
|
|
|
14372
|
+
// ../core/dist/composite/_web-fetch-helpers.js
|
|
14373
|
+
function splitEmojiSection(key) {
|
|
14374
|
+
const m = key.match(/^([^\p{L}\p{N}\s]+)\s+(.+)$/u);
|
|
14375
|
+
if (m)
|
|
14376
|
+
return { emoji: m[1], label: m[2] };
|
|
14377
|
+
return { emoji: null, label: key };
|
|
14378
|
+
}
|
|
14379
|
+
function reshapeWebFetchContent(content) {
|
|
14380
|
+
if (!content)
|
|
14381
|
+
return [];
|
|
14382
|
+
const sections = [];
|
|
14383
|
+
for (const [key, val] of Object.entries(content)) {
|
|
14384
|
+
if (!Array.isArray(val))
|
|
14385
|
+
continue;
|
|
14386
|
+
const { emoji, label } = splitEmojiSection(key);
|
|
14387
|
+
sections.push({
|
|
14388
|
+
section_label: label,
|
|
14389
|
+
section_emoji: emoji,
|
|
14390
|
+
entries: val
|
|
14391
|
+
});
|
|
14392
|
+
}
|
|
14393
|
+
sections.sort((a, b) => {
|
|
14394
|
+
const ai = SECTION_PRIORITY.findIndex((p) => a.section_label.toLowerCase().includes(p));
|
|
14395
|
+
const bi = SECTION_PRIORITY.findIndex((p) => b.section_label.toLowerCase().includes(p));
|
|
14396
|
+
const aN = ai < 0 ? SECTION_PRIORITY.length : ai;
|
|
14397
|
+
const bN = bi < 0 ? SECTION_PRIORITY.length : bi;
|
|
14398
|
+
if (aN !== bN)
|
|
14399
|
+
return aN - bN;
|
|
14400
|
+
return a.section_label.localeCompare(b.section_label);
|
|
14401
|
+
});
|
|
14402
|
+
return sections;
|
|
14403
|
+
}
|
|
14404
|
+
var SECTION_PRIORITY;
|
|
14405
|
+
var init_web_fetch_helpers = __esm({
|
|
14406
|
+
"../core/dist/composite/_web-fetch-helpers.js"() {
|
|
14407
|
+
"use strict";
|
|
14408
|
+
SECTION_PRIORITY = ["profile", "signals", "clues"];
|
|
14409
|
+
}
|
|
14410
|
+
});
|
|
14411
|
+
|
|
14266
14412
|
// ../core/dist/composite/prepare-outreach.js
|
|
14267
14413
|
function normalizeLinkedinPage2(v) {
|
|
14268
14414
|
if (v == null)
|
|
@@ -14274,14 +14420,16 @@ function normalizeLinkedinPage2(v) {
|
|
|
14274
14420
|
return null;
|
|
14275
14421
|
return trimmed;
|
|
14276
14422
|
}
|
|
14277
|
-
var prepareOutreach;
|
|
14423
|
+
var HISTORY_ACTIVITY_COUNT, prepareOutreach;
|
|
14278
14424
|
var init_prepare_outreach = __esm({
|
|
14279
14425
|
"../core/dist/composite/prepare-outreach.js"() {
|
|
14280
14426
|
"use strict";
|
|
14281
14427
|
init_get_lead_profile();
|
|
14282
14428
|
init_get_contacts();
|
|
14283
14429
|
init_enrich_contacts();
|
|
14430
|
+
init_web_fetch_helpers();
|
|
14284
14431
|
init_tool_descriptions_generated();
|
|
14432
|
+
HISTORY_ACTIVITY_COUNT = 20;
|
|
14285
14433
|
prepareOutreach = {
|
|
14286
14434
|
name: "leadbay_prepare_outreach",
|
|
14287
14435
|
annotations: {
|
|
@@ -14367,6 +14515,51 @@ var init_prepare_outreach = __esm({
|
|
|
14367
14515
|
hint: { type: ["string", "null"] }
|
|
14368
14516
|
}
|
|
14369
14517
|
},
|
|
14518
|
+
qualification: {
|
|
14519
|
+
type: ["array", "null"],
|
|
14520
|
+
description: "The lead's AI qualification answers ({question, score, response, computed_at, outdated_at}). Empty when none were computed; null when they could not be read.",
|
|
14521
|
+
items: { type: "object" }
|
|
14522
|
+
},
|
|
14523
|
+
signals: {
|
|
14524
|
+
type: ["array", "null"],
|
|
14525
|
+
description: "Web-research signals, the same priority-ordered sections leadbay_research_lead_by_id returns ({section_label, section_emoji, entries[]}). Empty when the lead was never researched or its research could not be read; null when the profile could not be read.",
|
|
14526
|
+
items: { type: "object" }
|
|
14527
|
+
},
|
|
14528
|
+
history: {
|
|
14529
|
+
type: "object",
|
|
14530
|
+
description: "What the team already did on this lead and its people.",
|
|
14531
|
+
properties: {
|
|
14532
|
+
notes: {
|
|
14533
|
+
type: "array",
|
|
14534
|
+
description: "Every note on the lead and on each of its org contacts, newest first: {date, author, contact, note}. `contact` names the person a note is about, null for a note on the company.",
|
|
14535
|
+
items: {
|
|
14536
|
+
type: "object",
|
|
14537
|
+
properties: {
|
|
14538
|
+
date: { type: "string" },
|
|
14539
|
+
author: { type: ["string", "null"] },
|
|
14540
|
+
contact: { type: ["string", "null"] },
|
|
14541
|
+
note: { type: "string" }
|
|
14542
|
+
}
|
|
14543
|
+
}
|
|
14544
|
+
},
|
|
14545
|
+
activities: {
|
|
14546
|
+
type: "array",
|
|
14547
|
+
description: `The ${HISTORY_ACTIVITY_COUNT} most recent timeline entries, newest first: {type, date}.`,
|
|
14548
|
+
items: {
|
|
14549
|
+
type: "object",
|
|
14550
|
+
properties: {
|
|
14551
|
+
type: { type: "string" },
|
|
14552
|
+
date: { type: "string" }
|
|
14553
|
+
}
|
|
14554
|
+
}
|
|
14555
|
+
},
|
|
14556
|
+
activities_total: {
|
|
14557
|
+
type: "number",
|
|
14558
|
+
description: "Timeline entries on the server, which can exceed the number returned."
|
|
14559
|
+
}
|
|
14560
|
+
},
|
|
14561
|
+
required: ["notes", "activities", "activities_total"]
|
|
14562
|
+
},
|
|
14370
14563
|
_meta: {
|
|
14371
14564
|
type: "object",
|
|
14372
14565
|
description: "Operator context: agent memory summary when enabled."
|
|
@@ -14376,7 +14569,8 @@ var init_prepare_outreach = __esm({
|
|
|
14376
14569
|
"recommended_contact",
|
|
14377
14570
|
"additional_contacts_count",
|
|
14378
14571
|
"total_contacts_count",
|
|
14379
|
-
"enrichment"
|
|
14572
|
+
"enrichment",
|
|
14573
|
+
"history"
|
|
14380
14574
|
]
|
|
14381
14575
|
},
|
|
14382
14576
|
execute: async (client, params, ctx) => {
|
|
@@ -14402,9 +14596,30 @@ var init_prepare_outreach = __esm({
|
|
|
14402
14596
|
}
|
|
14403
14597
|
}
|
|
14404
14598
|
const recommendedFresh = refreshed.find((c) => c.recommended) ?? recommended;
|
|
14599
|
+
const profileP = getLeadProfile.execute(client, { leadId: params.leadId }, ctx).then((profile) => ({ ok: true, profile }), (error) => ({ ok: false, error }));
|
|
14600
|
+
const orgContacts = refreshed.filter((c) => c.source === "org");
|
|
14601
|
+
const [leadNotes, contactNotes, activities] = await Promise.all([
|
|
14602
|
+
client.request("GET", `/leads/${params.leadId}/notes`),
|
|
14603
|
+
Promise.all(orgContacts.map(async (c) => {
|
|
14604
|
+
const notes2 = await client.request("GET", `/contacts/${c.id}/notes`);
|
|
14605
|
+
const name = [c.first_name, c.last_name].filter(Boolean).join(" ");
|
|
14606
|
+
return notes2.map((n) => ({
|
|
14607
|
+
date: n.created_at,
|
|
14608
|
+
author: null,
|
|
14609
|
+
contact: name || null,
|
|
14610
|
+
note: n.note
|
|
14611
|
+
}));
|
|
14612
|
+
})),
|
|
14613
|
+
client.request("GET", `/leads/${params.leadId}/activities?count=${HISTORY_ACTIVITY_COUNT}`)
|
|
14614
|
+
]);
|
|
14615
|
+
const profileR = await profileP;
|
|
14405
14616
|
let leadBlock = null;
|
|
14617
|
+
let qualification = null;
|
|
14618
|
+
let signals = null;
|
|
14406
14619
|
try {
|
|
14407
|
-
|
|
14620
|
+
if (!profileR.ok)
|
|
14621
|
+
throw profileR.error;
|
|
14622
|
+
const profile = profileR.profile;
|
|
14408
14623
|
const p = profile.lead;
|
|
14409
14624
|
leadBlock = {
|
|
14410
14625
|
id: p.id ?? params.leadId,
|
|
@@ -14422,6 +14637,8 @@ var init_prepare_outreach = __esm({
|
|
|
14422
14637
|
social_presence: p.social_presence ?? null,
|
|
14423
14638
|
social_urls: p.social_urls ?? null
|
|
14424
14639
|
};
|
|
14640
|
+
qualification = profile.qualification ?? null;
|
|
14641
|
+
signals = reshapeWebFetchContent(profile.web_insights ?? null);
|
|
14425
14642
|
} catch {
|
|
14426
14643
|
leadBlock = {
|
|
14427
14644
|
id: params.leadId,
|
|
@@ -14442,8 +14659,27 @@ var init_prepare_outreach = __esm({
|
|
|
14442
14659
|
const total = refreshed.length;
|
|
14443
14660
|
const additional = recommendedFresh ? Math.max(0, total - 1) : total;
|
|
14444
14661
|
const enrichmentComplete = Boolean(recommendedContact && (recommendedContact.email || recommendedContact.phone_number));
|
|
14662
|
+
const notes = [
|
|
14663
|
+
...leadNotes.map((n) => ({
|
|
14664
|
+
date: n.created_at,
|
|
14665
|
+
author: n.created_by ?? null,
|
|
14666
|
+
contact: null,
|
|
14667
|
+
note: n.note
|
|
14668
|
+
})),
|
|
14669
|
+
...contactNotes.flat()
|
|
14670
|
+
].sort((a, b) => b.date.localeCompare(a.date));
|
|
14445
14671
|
return {
|
|
14446
14672
|
lead: leadBlock,
|
|
14673
|
+
qualification,
|
|
14674
|
+
signals,
|
|
14675
|
+
history: {
|
|
14676
|
+
notes,
|
|
14677
|
+
activities: activities.items.map((a) => ({
|
|
14678
|
+
type: a.type,
|
|
14679
|
+
date: a.date
|
|
14680
|
+
})),
|
|
14681
|
+
activities_total: activities.pagination.total
|
|
14682
|
+
},
|
|
14447
14683
|
recommended_contact: recommendedContact,
|
|
14448
14684
|
additional_contacts_count: additional,
|
|
14449
14685
|
total_contacts_count: total,
|
|
@@ -14813,8 +15049,7 @@ var init_pull_leads = __esm({
|
|
|
14813
15049
|
description: "Operator context: region + last-call latency.",
|
|
14814
15050
|
properties: {
|
|
14815
15051
|
region: { type: "string" },
|
|
14816
|
-
latency_ms: { type: ["number", "null"] }
|
|
14817
|
-
agent_memory: { type: "object" }
|
|
15052
|
+
latency_ms: { type: ["number", "null"] }
|
|
14818
15053
|
}
|
|
14819
15054
|
}
|
|
14820
15055
|
},
|
|
@@ -14829,7 +15064,7 @@ var init_pull_leads = __esm({
|
|
|
14829
15064
|
if (resolvedOrder.error)
|
|
14830
15065
|
return resolvedOrder.error;
|
|
14831
15066
|
const orderQs = resolvedOrder.order ? `&order=${encodeURIComponent(resolvedOrder.order)}` : "";
|
|
14832
|
-
const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true${orderQs}`);
|
|
15067
|
+
const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true&exclude_handled=true${orderQs}`);
|
|
14833
15068
|
if (params._reportSeen !== false) {
|
|
14834
15069
|
reportLeadInteractions(client, lensId, res.items.map((lead) => lead.id), ["LEAD_SEEN"], ctx?.logger);
|
|
14835
15070
|
}
|
|
@@ -15063,6 +15298,23 @@ function augmentContact(c) {
|
|
|
15063
15298
|
linkedin_page: normalizeLinkedinPage4(c.linkedin_page ?? null)
|
|
15064
15299
|
};
|
|
15065
15300
|
}
|
|
15301
|
+
async function lastLoggedEmail(client, leadId) {
|
|
15302
|
+
const notes = await client.request("GET", `/leads/${leadId}/notes`).catch(() => null);
|
|
15303
|
+
let last = null;
|
|
15304
|
+
for (const n of Array.isArray(notes) ? notes : []) {
|
|
15305
|
+
const m = typeof n?.note === "string" ? LOGGED_GMAIL_ID.exec(n.note) : null;
|
|
15306
|
+
if (m && (!last || Date.parse(n.created_at) > Date.parse(last.logged_at))) {
|
|
15307
|
+
last = { gmail_message_id: m[1], logged_at: n.created_at };
|
|
15308
|
+
}
|
|
15309
|
+
}
|
|
15310
|
+
return last;
|
|
15311
|
+
}
|
|
15312
|
+
function outreachSyncOffer(leads) {
|
|
15313
|
+
const unlogged = leads.filter((l) => !l?.last_prospecting_action_at).length;
|
|
15314
|
+
if (leads.length === 0 || unlogged * 2 <= leads.length)
|
|
15315
|
+
return null;
|
|
15316
|
+
return `Leadbay has no outreach logged for ${unlogged} of the ${leads.length} leads on this page, so it cannot tell who was contacted, when, or who went quiet. After answering, offer once to set up a daily task that reads the user's mailbox and calendar and logs each email and meeting on the person in Leadbay. If they accept, take the instruction under "Logging outreach from the user's mailbox and calendar" in the leadbay_report_outreach description: run it now over the last 14 days, then schedule it as a daily task with that instruction word for word. Without a mail or calendar tool, tell them to connect Gmail or Google Calendar first.`;
|
|
15317
|
+
}
|
|
15066
15318
|
function mergeLocationIds(filter, ids) {
|
|
15067
15319
|
const criteria = filter?.criteria ? [...filter.criteria] : [];
|
|
15068
15320
|
const idx = criteria.findIndex((c) => c?.type === "location_ids" && c?.is_excluded === false);
|
|
@@ -15080,7 +15332,60 @@ function mergeLocationIds(filter, ids) {
|
|
|
15080
15332
|
}
|
|
15081
15333
|
return { criteria };
|
|
15082
15334
|
}
|
|
15083
|
-
|
|
15335
|
+
function buildFollowupNextSteps(leadCount2, hasMore, nextPage, hasActiveFilter) {
|
|
15336
|
+
if (leadCount2 === 0)
|
|
15337
|
+
return null;
|
|
15338
|
+
const options = [];
|
|
15339
|
+
options.push({
|
|
15340
|
+
label: "Call board",
|
|
15341
|
+
description: "Build an interactive call board to work these leads and log outreach.",
|
|
15342
|
+
kind: "build_artifact"
|
|
15343
|
+
});
|
|
15344
|
+
options.push({
|
|
15345
|
+
label: "Prep outreach",
|
|
15346
|
+
description: "Prepare a call opener and email for the top lead.",
|
|
15347
|
+
kind: "enrich_top_leads"
|
|
15348
|
+
});
|
|
15349
|
+
options.push({
|
|
15350
|
+
label: "Coverage board",
|
|
15351
|
+
description: hasActiveFilter ? "Build a coverage board measuring this filtered slice against the whole book \u2014 the filter is server-stored, so measure unfiltered for the denominator." : "Build a coverage board measuring how much of the portfolio sits in each sector or city.",
|
|
15352
|
+
kind: "build_artifact"
|
|
15353
|
+
});
|
|
15354
|
+
if (hasMore && nextPage != null) {
|
|
15355
|
+
options.push({
|
|
15356
|
+
label: "Next page",
|
|
15357
|
+
description: `Pull page ${nextPage + 1} of the Monitor.`,
|
|
15358
|
+
kind: "pull_next_page"
|
|
15359
|
+
});
|
|
15360
|
+
}
|
|
15361
|
+
return { question: "What do you want to do next?", options: options.slice(0, 4) };
|
|
15362
|
+
}
|
|
15363
|
+
function filterLanded(sent, echoed) {
|
|
15364
|
+
if (!sent)
|
|
15365
|
+
return null;
|
|
15366
|
+
const sentCriteria = Array.isArray(sent.criteria) ? sent.criteria : [];
|
|
15367
|
+
if (sentCriteria.length === 0)
|
|
15368
|
+
return null;
|
|
15369
|
+
const typeOf = (c) => {
|
|
15370
|
+
if (!c || typeof c !== "object")
|
|
15371
|
+
return null;
|
|
15372
|
+
const t = c.type;
|
|
15373
|
+
return typeof t === "string" && t ? t : null;
|
|
15374
|
+
};
|
|
15375
|
+
const echoedCriteria = Array.isArray(echoed?.criteria) ? echoed.criteria : [];
|
|
15376
|
+
const got = new Set(echoedCriteria.map(typeOf).filter((t) => t != null));
|
|
15377
|
+
for (const c of sentCriteria) {
|
|
15378
|
+
if (c == null || typeof c !== "object")
|
|
15379
|
+
continue;
|
|
15380
|
+
const t = typeOf(c);
|
|
15381
|
+
if (t == null)
|
|
15382
|
+
return false;
|
|
15383
|
+
if (!got.has(t))
|
|
15384
|
+
return false;
|
|
15385
|
+
}
|
|
15386
|
+
return true;
|
|
15387
|
+
}
|
|
15388
|
+
var LOGGED_GMAIL_ID, REPLY_CHECK, pullFollowups;
|
|
15084
15389
|
var init_pull_followups = __esm({
|
|
15085
15390
|
"../core/dist/composite/pull-followups.js"() {
|
|
15086
15391
|
"use strict";
|
|
@@ -15088,6 +15393,8 @@ var init_pull_followups = __esm({
|
|
|
15088
15393
|
init_tool_descriptions_generated();
|
|
15089
15394
|
init_geo_helpers();
|
|
15090
15395
|
init_country_guard();
|
|
15396
|
+
LOGGED_GMAIL_ID = /— logged by AI agent \(verification: gmail_message_id=([^\s)]+)\)\s*$/;
|
|
15397
|
+
REPLY_CHECK = "`last_logged_email` on a lead is the Gmail message id logged when that email was sent. Before rendering, open each message by that id with your mail tool, then read the thread it belongs to: an email sent inside an earlier thread has a message id that is not a thread id. A message from the contact dated after `logged_at` is a reply Leadbay has not seen: log it with leadbay_report_outreach({lead_id, note: 'Replied: <one line of what they said>', verification: {source: 'gmail_message_id', ref: <the reply's message id>}}) without epilogue_status, and show that row as replied. The outcome is the user's to state: ask them, as after any outreach, and set it only from their answer. Once logged, the reply is older than the next `logged_at`, so it is never reported twice. A message your mail tool cannot find was sent from another mailbox: skip it. With no mail tool, tell the user replies were not checked.";
|
|
15091
15398
|
pullFollowups = {
|
|
15092
15399
|
name: "leadbay_pull_followups",
|
|
15093
15400
|
annotations: {
|
|
@@ -15154,15 +15461,27 @@ var init_pull_followups = __esm({
|
|
|
15154
15461
|
type: ["object", "null"],
|
|
15155
15462
|
description: "The FilterItem currently stored server-side for this user (via GET /monitor/filter). null when no filter is set or when filtered:false was passed."
|
|
15156
15463
|
},
|
|
15464
|
+
filter_applied: {
|
|
15465
|
+
type: ["boolean", "null"],
|
|
15466
|
+
description: "Whether the `set_filter` just sent actually landed. null when no set_filter was passed. FALSE means the backend answered 200 but stored nothing \u2014 every count and row below belongs to the PREVIOUS filter, echoed in `active_filters`, NOT to what was requested. Do not report those figures as the requested segment; re-send with a `type` discriminator on each criterion."
|
|
15467
|
+
},
|
|
15157
15468
|
leads: {
|
|
15158
15469
|
type: "array",
|
|
15159
|
-
description: "The page of monitored leads. Each lead carries the FullLead shape augmented with normalized linkedin_page on contacts and `recommended_contact`.",
|
|
15470
|
+
description: "The page of monitored leads. Each lead carries the FullLead shape augmented with normalized linkedin_page on contacts and `recommended_contact`. A lead whose latest email was logged with a Gmail message id also carries `last_logged_email: {gmail_message_id, logged_at}`.",
|
|
15160
15471
|
items: { type: "object" }
|
|
15161
15472
|
},
|
|
15473
|
+
reply_check: {
|
|
15474
|
+
type: "string",
|
|
15475
|
+
description: "Present when at least one lead carries `last_logged_email`: how to look for replies in the user's mailbox and log them before rendering."
|
|
15476
|
+
},
|
|
15162
15477
|
pagination: {
|
|
15163
15478
|
type: ["object", "null"],
|
|
15164
15479
|
description: "page / pages / total \u2014 the backend's pagination envelope when present."
|
|
15165
15480
|
},
|
|
15481
|
+
outreach_sync: {
|
|
15482
|
+
type: "string",
|
|
15483
|
+
description: "Present when more than half the leads on the page have no outreach logged: the offer of the daily mailbox and calendar sync, and how to run it."
|
|
15484
|
+
},
|
|
15166
15485
|
total_excluded_by_pushback: {
|
|
15167
15486
|
type: "number",
|
|
15168
15487
|
description: "Composite-derived count of leads in the page that were excluded because their `pushback_status` is active. The backend may or may not pre-filter; this exposes the count when the composite has to drop them itself."
|
|
@@ -15181,13 +15500,31 @@ var init_pull_followups = __esm({
|
|
|
15181
15500
|
description: "Per offending value: {value, param, kind, country, axis, kept}. Only present when `status === 'country_level_location'`. The recovery BRANCHES on `country_locations[].axis` and `[].kind`; `hint` states the one for THIS call \u2014 follow it verbatim. Dropping the argument is NOT the general answer: on an `exclude` axis it returns the very companies the user asked to remove, and for a `foreign_country` an unfiltered result is this workspace's own leads, which answer a different question. Never retry with another spelling or a nearby city.",
|
|
15182
15501
|
items: { type: "object" }
|
|
15183
15502
|
},
|
|
15503
|
+
next_steps: {
|
|
15504
|
+
type: ["object", "null"],
|
|
15505
|
+
description: "Deterministic follow-on offers, artifact option FIRST. Map `options[]` into the host's next-step widget VERBATIM and in order \u2014 do not reword, reorder or drop them. null when the page is empty. Each option: {label (\u22645 words), description (the full sentence), kind}.",
|
|
15506
|
+
properties: {
|
|
15507
|
+
question: { type: "string" },
|
|
15508
|
+
options: {
|
|
15509
|
+
type: "array",
|
|
15510
|
+
items: {
|
|
15511
|
+
type: "object",
|
|
15512
|
+
properties: {
|
|
15513
|
+
label: { type: "string" },
|
|
15514
|
+
description: { type: "string" },
|
|
15515
|
+
kind: { type: "string" }
|
|
15516
|
+
},
|
|
15517
|
+
required: ["label", "description", "kind"]
|
|
15518
|
+
}
|
|
15519
|
+
}
|
|
15520
|
+
}
|
|
15521
|
+
},
|
|
15184
15522
|
_meta: {
|
|
15185
15523
|
type: "object",
|
|
15186
15524
|
description: "Operator context: region + last-call latency.",
|
|
15187
15525
|
properties: {
|
|
15188
15526
|
region: { type: "string" },
|
|
15189
|
-
latency_ms: { type: ["number", "null"] }
|
|
15190
|
-
agent_memory: { type: "object" }
|
|
15527
|
+
latency_ms: { type: ["number", "null"] }
|
|
15191
15528
|
}
|
|
15192
15529
|
}
|
|
15193
15530
|
},
|
|
@@ -15303,11 +15640,27 @@ var init_pull_followups = __esm({
|
|
|
15303
15640
|
recommended_contact: augmentContact(lead.recommended_contact),
|
|
15304
15641
|
org_contacts: Array.isArray(lead.org_contacts) ? lead.org_contacts.map(augmentContact) : lead.org_contacts ?? null
|
|
15305
15642
|
}));
|
|
15643
|
+
const emails = await Promise.all(leads.map((lead) => lead.notes_count > 0 ? lastLoggedEmail(client, lead.id) : null));
|
|
15644
|
+
emails.forEach((email, i) => {
|
|
15645
|
+
if (email)
|
|
15646
|
+
leads[i].last_logged_email = email;
|
|
15647
|
+
});
|
|
15648
|
+
const pageInfo = monitor.pagination ?? null;
|
|
15649
|
+
const currentPage = typeof pageInfo?.page === "number" ? pageInfo.page : 0;
|
|
15650
|
+
const totalPages = typeof pageInfo?.pages === "number" ? pageInfo.pages : 0;
|
|
15651
|
+
const moreToCome = totalPages > currentPage + 1;
|
|
15652
|
+
const filterCriteria = activeFilter?.criteria;
|
|
15653
|
+
const landed = filterLanded(effectiveSetFilter, activeFilter);
|
|
15654
|
+
const outreachSync = outreachSyncOffer(leads);
|
|
15306
15655
|
return {
|
|
15307
15656
|
active_filters: activeFilter,
|
|
15657
|
+
filter_applied: landed,
|
|
15308
15658
|
leads,
|
|
15309
|
-
pagination:
|
|
15659
|
+
pagination: pageInfo,
|
|
15310
15660
|
total_excluded_by_pushback: excluded,
|
|
15661
|
+
...emails.some(Boolean) ? { reply_check: REPLY_CHECK } : {},
|
|
15662
|
+
...outreachSync ? { outreach_sync: outreachSync } : {},
|
|
15663
|
+
next_steps: buildFollowupNextSteps(leads.length, moreToCome, moreToCome ? currentPage + 1 : null, Array.isArray(filterCriteria) && filterCriteria.length > 0),
|
|
15311
15664
|
_meta: {
|
|
15312
15665
|
region: client.region,
|
|
15313
15666
|
latency_ms: client.lastMeta?.latency_ms ?? null
|
|
@@ -16305,46 +16658,6 @@ var init_campaign_call_sheet = __esm({
|
|
|
16305
16658
|
}
|
|
16306
16659
|
});
|
|
16307
16660
|
|
|
16308
|
-
// ../core/dist/composite/_web-fetch-helpers.js
|
|
16309
|
-
function splitEmojiSection(key) {
|
|
16310
|
-
const m = key.match(/^([^\p{L}\p{N}\s]+)\s+(.+)$/u);
|
|
16311
|
-
if (m)
|
|
16312
|
-
return { emoji: m[1], label: m[2] };
|
|
16313
|
-
return { emoji: null, label: key };
|
|
16314
|
-
}
|
|
16315
|
-
function reshapeWebFetchContent(content) {
|
|
16316
|
-
if (!content)
|
|
16317
|
-
return [];
|
|
16318
|
-
const sections = [];
|
|
16319
|
-
for (const [key, val] of Object.entries(content)) {
|
|
16320
|
-
if (!Array.isArray(val))
|
|
16321
|
-
continue;
|
|
16322
|
-
const { emoji, label } = splitEmojiSection(key);
|
|
16323
|
-
sections.push({
|
|
16324
|
-
section_label: label,
|
|
16325
|
-
section_emoji: emoji,
|
|
16326
|
-
entries: val
|
|
16327
|
-
});
|
|
16328
|
-
}
|
|
16329
|
-
sections.sort((a, b) => {
|
|
16330
|
-
const ai = SECTION_PRIORITY.findIndex((p) => a.section_label.toLowerCase().includes(p));
|
|
16331
|
-
const bi = SECTION_PRIORITY.findIndex((p) => b.section_label.toLowerCase().includes(p));
|
|
16332
|
-
const aN = ai < 0 ? SECTION_PRIORITY.length : ai;
|
|
16333
|
-
const bN = bi < 0 ? SECTION_PRIORITY.length : bi;
|
|
16334
|
-
if (aN !== bN)
|
|
16335
|
-
return aN - bN;
|
|
16336
|
-
return a.section_label.localeCompare(b.section_label);
|
|
16337
|
-
});
|
|
16338
|
-
return sections;
|
|
16339
|
-
}
|
|
16340
|
-
var SECTION_PRIORITY;
|
|
16341
|
-
var init_web_fetch_helpers = __esm({
|
|
16342
|
-
"../core/dist/composite/_web-fetch-helpers.js"() {
|
|
16343
|
-
"use strict";
|
|
16344
|
-
SECTION_PRIORITY = ["profile", "signals", "clues"];
|
|
16345
|
-
}
|
|
16346
|
-
});
|
|
16347
|
-
|
|
16348
16661
|
// ../core/dist/composite/research-lead-by-id.js
|
|
16349
16662
|
function normalizeLinkedinPage5(v) {
|
|
16350
16663
|
if (v == null)
|
|
@@ -16692,8 +17005,7 @@ var init_research_lead_by_id = __esm({
|
|
|
16692
17005
|
resolved_matched_on: {
|
|
16693
17006
|
type: ["array", "null"],
|
|
16694
17007
|
items: { type: "string" }
|
|
16695
|
-
}
|
|
16696
|
-
agent_memory: { type: "object" }
|
|
17008
|
+
}
|
|
16697
17009
|
},
|
|
16698
17010
|
// _meta is an open envelope: the MCP server layer injects
|
|
16699
17011
|
// additional keys (latency_ms, notifications, update_available)
|
|
@@ -17131,7 +17443,17 @@ async function hydrateAmbiguous(client, candidates, lensId) {
|
|
|
17131
17443
|
};
|
|
17132
17444
|
});
|
|
17133
17445
|
}
|
|
17134
|
-
|
|
17446
|
+
function recentLookups(caller, now) {
|
|
17447
|
+
for (const [key, lookups] of lookupsByCaller) {
|
|
17448
|
+
const kept = lookups.filter((l) => now - l.at < LIMIT_WINDOW_MS);
|
|
17449
|
+
if (kept.length === 0)
|
|
17450
|
+
lookupsByCaller.delete(key);
|
|
17451
|
+
else
|
|
17452
|
+
lookupsByCaller.set(key, kept);
|
|
17453
|
+
}
|
|
17454
|
+
return lookupsByCaller.get(caller) ?? [];
|
|
17455
|
+
}
|
|
17456
|
+
var RESOLVE_TIMEOUT_MS, MAX_AMBIGUOUS_CANDIDATES, researchLeadByNameFuzzy, NOTICE_WINDOW_MS, NOTICE_FROM, LIMIT_WINDOW_MS, LIMIT, lookupsByCaller, LIST_IN_ONE_CALL, lookUpByName;
|
|
17135
17457
|
var init_research_lead_by_name_fuzzy = __esm({
|
|
17136
17458
|
"../core/dist/composite/research-lead-by-name-fuzzy.js"() {
|
|
17137
17459
|
"use strict";
|
|
@@ -17334,6 +17656,45 @@ var init_research_lead_by_name_fuzzy = __esm({
|
|
|
17334
17656
|
};
|
|
17335
17657
|
}
|
|
17336
17658
|
};
|
|
17659
|
+
NOTICE_WINDOW_MS = 10 * 60 * 1e3;
|
|
17660
|
+
NOTICE_FROM = 20;
|
|
17661
|
+
LIMIT_WINDOW_MS = 60 * 60 * 1e3;
|
|
17662
|
+
LIMIT = 1e3;
|
|
17663
|
+
lookupsByCaller = /* @__PURE__ */ new Map();
|
|
17664
|
+
LIST_IN_ONE_CALL = "one leadbay_qualify_leads call takes the whole list, up to 500 companies, as lead_refs [{name}] with website when known. With qualify:false it is free and returns one row per company with its website and LinkedIn.";
|
|
17665
|
+
lookUpByName = researchLeadByNameFuzzy.execute;
|
|
17666
|
+
researchLeadByNameFuzzy.execute = async (client, params, ctx) => {
|
|
17667
|
+
const caller = client.callerKey();
|
|
17668
|
+
if (caller === null)
|
|
17669
|
+
return await lookUpByName(client, params, ctx);
|
|
17670
|
+
const now = Date.now();
|
|
17671
|
+
const lookups = recentLookups(caller, now);
|
|
17672
|
+
if (lookups.length >= LIMIT) {
|
|
17673
|
+
const notFound = lookups.filter((l) => l.notFound).length;
|
|
17674
|
+
const retryAfter = Math.ceil((lookups[0].at + LIMIT_WINDOW_MS - now) / 1e3);
|
|
17675
|
+
throw client.makeError("TOO_MANY_LOOKUPS", `${lookups.length} companies were looked up by name in the last hour, ${notFound} of them not found. This tool takes another lookup in ${retryAfter} seconds`, `Stop calling this tool once per company, and tell the user how far the list got. For the companies not looked up yet, ${LIST_IN_ONE_CALL} For one company the user asks about by name, call this tool again after ${retryAfter} seconds.`, void 0, retryAfter);
|
|
17676
|
+
}
|
|
17677
|
+
const lookup = { at: now, notFound: false };
|
|
17678
|
+
lookupsByCaller.set(caller, [...lookups, lookup]);
|
|
17679
|
+
const result = await lookUpByName(client, params, ctx);
|
|
17680
|
+
if (result?.resolution === "not_found")
|
|
17681
|
+
lookup.notFound = true;
|
|
17682
|
+
const seen = (lookupsByCaller.get(caller) ?? [lookup]).filter((l) => now - l.at < NOTICE_WINDOW_MS);
|
|
17683
|
+
if (seen.length < NOTICE_FROM || result === null || typeof result !== "object") {
|
|
17684
|
+
return result;
|
|
17685
|
+
}
|
|
17686
|
+
const notice = `Lookup ${seen.length} by name in the last 10 minutes, ${seen.filter((l) => l.notFound).length} not found. If the user wants only whether each company is in Leadbay, its website and its LinkedIn, ${LIST_IN_ONE_CALL} If they need each company's research card, carry on. After ${LIMIT} lookups in an hour, this tool refuses new ones until the oldest is an hour old.`;
|
|
17687
|
+
if (result.__markdown_envelope === true) {
|
|
17688
|
+
return {
|
|
17689
|
+
...result,
|
|
17690
|
+
markdown: `> ${notice}
|
|
17691
|
+
|
|
17692
|
+
${result.markdown}`,
|
|
17693
|
+
structured: { ...result.structured, notice }
|
|
17694
|
+
};
|
|
17695
|
+
}
|
|
17696
|
+
return { ...result, notice };
|
|
17697
|
+
};
|
|
17337
17698
|
}
|
|
17338
17699
|
});
|
|
17339
17700
|
|
|
@@ -17351,6 +17712,63 @@ function formWarnings(questions) {
|
|
|
17351
17712
|
}
|
|
17352
17713
|
return out;
|
|
17353
17714
|
}
|
|
17715
|
+
async function addAntiPatterns(client, toAdd) {
|
|
17716
|
+
const me = await client.resolveMe();
|
|
17717
|
+
if (me.admin !== true) {
|
|
17718
|
+
return {
|
|
17719
|
+
error: true,
|
|
17720
|
+
code: "FORBIDDEN",
|
|
17721
|
+
message: "Changing the ideal buyer profile requires admin rights on the org",
|
|
17722
|
+
hint: "Nothing was saved. Tell the user an org admin has to add it, and show the current criteria with leadbay_get_qualification_questions."
|
|
17723
|
+
};
|
|
17724
|
+
}
|
|
17725
|
+
const orgId = me.organization.id;
|
|
17726
|
+
const [questions, ibp] = await Promise.all([
|
|
17727
|
+
client.request("GET", `/organizations/${orgId}/ai_agent_questions`),
|
|
17728
|
+
client.request("GET", `/organizations/${orgId}/ideal_buyer_profile`)
|
|
17729
|
+
]);
|
|
17730
|
+
const qs = (questions ?? []).map((q) => ({ question: q.question }));
|
|
17731
|
+
const base = { qualification_questions: qs, count: qs.length, previous_count: qs.length, region: client.region };
|
|
17732
|
+
if (!ibp?.summary) {
|
|
17733
|
+
return {
|
|
17734
|
+
...base,
|
|
17735
|
+
changed: false,
|
|
17736
|
+
hint: "This org has no ideal buyer profile yet, so there is nowhere to add a negative criterion. Use a qualification question or leadbay_refine_prompt for this rule instead."
|
|
17737
|
+
};
|
|
17738
|
+
}
|
|
17739
|
+
const key = (s) => s.trim().toLowerCase();
|
|
17740
|
+
const current = ibp.anti_patterns ?? [];
|
|
17741
|
+
const seen = new Set(current.map(key));
|
|
17742
|
+
const added = [];
|
|
17743
|
+
for (const a of toAdd.map((s) => s.trim())) {
|
|
17744
|
+
if (a.length > 0 && !seen.has(key(a))) {
|
|
17745
|
+
added.push(a);
|
|
17746
|
+
seen.add(key(a));
|
|
17747
|
+
}
|
|
17748
|
+
}
|
|
17749
|
+
if (added.length === 0) {
|
|
17750
|
+
return {
|
|
17751
|
+
...base,
|
|
17752
|
+
anti_patterns: current,
|
|
17753
|
+
changed: false,
|
|
17754
|
+
hint: "No change: the ideal buyer profile already lists every criterion passed. leadbay_get_qualification_questions shows them."
|
|
17755
|
+
};
|
|
17756
|
+
}
|
|
17757
|
+
const next = [...current, ...added];
|
|
17758
|
+
await client.requestVoid("POST", `/organizations/${orgId}/ideal_buyer_profile`, {
|
|
17759
|
+
summary: ibp.summary,
|
|
17760
|
+
key_characteristics: ibp.key_characteristics ?? [],
|
|
17761
|
+
anti_patterns: next
|
|
17762
|
+
});
|
|
17763
|
+
client.invalidateTasteProfile();
|
|
17764
|
+
return {
|
|
17765
|
+
...base,
|
|
17766
|
+
anti_patterns: next,
|
|
17767
|
+
anti_patterns_added: added,
|
|
17768
|
+
changed: true,
|
|
17769
|
+
hint: "Saved. Leads already scored keep their score until they are next qualified. Leadbay is regenerating its targeting from the profile in the background, and it no longer rewrites this profile by itself. leadbay_get_qualification_questions shows the updated profile."
|
|
17770
|
+
};
|
|
17771
|
+
}
|
|
17354
17772
|
var MAX_QUESTIONS, ESTIMATIVE_MARKERS, setQualificationQuestions;
|
|
17355
17773
|
var init_set_qualification_questions = __esm({
|
|
17356
17774
|
"../core/dist/composite/set-qualification-questions.js"() {
|
|
@@ -17391,6 +17809,11 @@ var init_set_qualification_questions = __esm({
|
|
|
17391
17809
|
items: { type: "string" },
|
|
17392
17810
|
description: "Exact question strings to remove from the current list. Mutually exclusive with `questions`. A removal requires confirm:true."
|
|
17393
17811
|
},
|
|
17812
|
+
add_anti_patterns: {
|
|
17813
|
+
type: "array",
|
|
17814
|
+
items: { type: "string" },
|
|
17815
|
+
description: `Negative criteria to append to the org's ideal buyer profile: kinds of company that are never the buyer ("Consulting firms and IT services companies", "Franchise locations of national chains"). Qualification reads them as negative signals when it scores a lead. Uses no question slot. Its own write: do not combine with questions/add/remove. Admin only. Read leadbay_get_qualification_questions first and skip anything an existing anti-pattern or question already says. In the user's language.`
|
|
17816
|
+
},
|
|
17394
17817
|
confirm: {
|
|
17395
17818
|
type: "boolean",
|
|
17396
17819
|
description: "Required whenever the change DROPS ANY existing question \u2014 including a same-count swap or a `questions` replacement that omits a current question, not only when the list gets shorter (removing a question changes how every lead is scored). Without it, such a change is previewed and not applied. Pure additions never need confirm."
|
|
@@ -17413,6 +17836,16 @@ var init_set_qualification_questions = __esm({
|
|
|
17413
17836
|
description: "True when the list was actually written; false on a no-op or an unconfirmed shrink."
|
|
17414
17837
|
},
|
|
17415
17838
|
region: { type: "string" },
|
|
17839
|
+
anti_patterns: {
|
|
17840
|
+
type: "array",
|
|
17841
|
+
items: { type: "string" },
|
|
17842
|
+
description: "The ideal buyer profile's negative criteria after an add_anti_patterns call."
|
|
17843
|
+
},
|
|
17844
|
+
anti_patterns_added: {
|
|
17845
|
+
type: "array",
|
|
17846
|
+
items: { type: "string" },
|
|
17847
|
+
description: "The criteria this call wrote."
|
|
17848
|
+
},
|
|
17416
17849
|
form_warnings: {
|
|
17417
17850
|
type: "array",
|
|
17418
17851
|
items: { type: "string" },
|
|
@@ -17430,11 +17863,18 @@ var init_set_qualification_questions = __esm({
|
|
|
17430
17863
|
const hasSet = Array.isArray(params.questions);
|
|
17431
17864
|
const hasAdd = Array.isArray(params.add) && params.add.length > 0;
|
|
17432
17865
|
const hasRemove = Array.isArray(params.remove) && params.remove.length > 0;
|
|
17866
|
+
const hasAntiPatterns = Array.isArray(params.add_anti_patterns) && params.add_anti_patterns.length > 0;
|
|
17867
|
+
if (hasAntiPatterns) {
|
|
17868
|
+
if (hasSet || hasAdd || hasRemove) {
|
|
17869
|
+
throw client.makeError("QUALIFICATION_QUESTIONS_BAD_ARGS", "`add_anti_patterns` is its own write and cannot be combined with questions/add/remove", "Call once for the questions and once for the buyer profile.", "POST /organizations/{orgId}/ideal_buyer_profile");
|
|
17870
|
+
}
|
|
17871
|
+
return addAntiPatterns(client, params.add_anti_patterns);
|
|
17872
|
+
}
|
|
17433
17873
|
if (hasSet && (hasAdd || hasRemove)) {
|
|
17434
17874
|
throw client.makeError("QUALIFICATION_QUESTIONS_BAD_ARGS", "`questions` (full replace) is mutually exclusive with add/remove", "Pass EITHER `questions` (the full new list) OR `add`/`remove`, not both.", "POST /organizations/{orgId}");
|
|
17435
17875
|
}
|
|
17436
17876
|
if (!hasSet && !hasAdd && !hasRemove) {
|
|
17437
|
-
throw client.makeError("QUALIFICATION_QUESTIONS_NO_CHANGE", "nothing to change \u2014 pass `questions`, `add`, or `
|
|
17877
|
+
throw client.makeError("QUALIFICATION_QUESTIONS_NO_CHANGE", "nothing to change \u2014 pass `questions`, `add`, `remove` or `add_anti_patterns`", "Provide a full `questions` list, `add`/`remove` entries, or `add_anti_patterns`.", "POST /organizations/{orgId}");
|
|
17438
17878
|
}
|
|
17439
17879
|
const orgId = await client.resolveOrgId();
|
|
17440
17880
|
const current = await client.request("GET", `/organizations/${orgId}/ai_agent_questions`);
|
|
@@ -18223,8 +18663,7 @@ var init_scan_portfolio_signals = __esm({
|
|
|
18223
18663
|
_meta: {
|
|
18224
18664
|
type: "object",
|
|
18225
18665
|
properties: {
|
|
18226
|
-
region: { type: "string" }
|
|
18227
|
-
agent_memory: { type: "object" }
|
|
18666
|
+
region: { type: "string" }
|
|
18228
18667
|
}
|
|
18229
18668
|
}
|
|
18230
18669
|
},
|
|
@@ -18544,6 +18983,31 @@ var init_recall_ordered_titles = __esm({
|
|
|
18544
18983
|
});
|
|
18545
18984
|
|
|
18546
18985
|
// ../core/dist/composite/account-status.js
|
|
18986
|
+
async function readSearchConfiguration(client, orgId, ctx) {
|
|
18987
|
+
const org = `/organizations/${orgId}`;
|
|
18988
|
+
const [ibp, prompt, questions, tags] = await Promise.allSettled([
|
|
18989
|
+
client.request("GET", `${org}/ideal_buyer_profile`),
|
|
18990
|
+
// The body is `{user_prompt}`, and 204 when unset (api-specs UserPrompt.yml).
|
|
18991
|
+
client.request("GET", `${org}/user_prompt`),
|
|
18992
|
+
client.request("GET", `${org}/ai_agent_questions`),
|
|
18993
|
+
client.request("GET", `${org}/purchase_intent_tags`)
|
|
18994
|
+
]);
|
|
18995
|
+
const failed = [ibp, prompt, questions, tags].filter((r) => r.status === "rejected").length;
|
|
18996
|
+
if (failed > 0) {
|
|
18997
|
+
ctx?.logger?.warn?.(`account_status: ${failed} of 4 search-configuration read(s) failed`);
|
|
18998
|
+
}
|
|
18999
|
+
const profile = ibp.status === "fulfilled" ? ibp.value : null;
|
|
19000
|
+
return {
|
|
19001
|
+
ideal_buyer_profile: profile?.summary ? {
|
|
19002
|
+
summary: profile.summary,
|
|
19003
|
+
key_characteristics: profile.key_characteristics ?? [],
|
|
19004
|
+
anti_patterns: profile.anti_patterns ?? []
|
|
19005
|
+
} : null,
|
|
19006
|
+
targeting_prompt: prompt.status === "fulfilled" ? prompt.value?.user_prompt || null : null,
|
|
19007
|
+
qualification_questions: questions.status === "fulfilled" ? (questions.value ?? []).map((q) => q.question) : null,
|
|
19008
|
+
purchase_intent_tags: tags.status === "fulfilled" ? (tags.value ?? []).map((t) => t.display_name) : null
|
|
19009
|
+
};
|
|
19010
|
+
}
|
|
18547
19011
|
var accountStatus;
|
|
18548
19012
|
var init_account_status = __esm({
|
|
18549
19013
|
"../core/dist/composite/account-status.js"() {
|
|
@@ -18602,6 +19066,35 @@ var init_account_status = __esm({
|
|
|
18602
19066
|
type: ["string", "null"],
|
|
18603
19067
|
description: "Human-readable name of the active lens (resolved from /lenses). WITHHELD (null) unless the user explicitly asked about the lens/audience \u2014 the composite only resolves it when asked, so on a plain account question there is nothing here to mention. When present (the user asked), answer with THIS name, never the numeric id."
|
|
18604
19068
|
},
|
|
19069
|
+
search_configuration: {
|
|
19070
|
+
type: "object",
|
|
19071
|
+
description: "What this account is configured to search for \u2014 the org's targeting settings. A null field is unset OR could not be read: say nothing about it, and never tell the user it is missing. An empty list means none are configured.",
|
|
19072
|
+
properties: {
|
|
19073
|
+
ideal_buyer_profile: {
|
|
19074
|
+
type: ["object", "null"],
|
|
19075
|
+
description: "The org's Ideal Buyer Profile: who Leadbay looks for.",
|
|
19076
|
+
properties: {
|
|
19077
|
+
summary: { type: "string" },
|
|
19078
|
+
key_characteristics: { type: "array", items: { type: "string" } },
|
|
19079
|
+
anti_patterns: { type: "array", items: { type: "string" } }
|
|
19080
|
+
}
|
|
19081
|
+
},
|
|
19082
|
+
targeting_prompt: {
|
|
19083
|
+
type: ["string", "null"],
|
|
19084
|
+
description: "The org's free-text targeting prompt, in the user's own words. Changed with leadbay_refine_prompt."
|
|
19085
|
+
},
|
|
19086
|
+
qualification_questions: {
|
|
19087
|
+
type: ["array", "null"],
|
|
19088
|
+
items: { type: "string" },
|
|
19089
|
+
description: "The questions Leadbay scores every lead against. Changed with leadbay_set_qualification_questions."
|
|
19090
|
+
},
|
|
19091
|
+
purchase_intent_tags: {
|
|
19092
|
+
type: ["array", "null"],
|
|
19093
|
+
items: { type: "string" },
|
|
19094
|
+
description: "The buying signals Leadbay watches for, by name."
|
|
19095
|
+
}
|
|
19096
|
+
}
|
|
19097
|
+
},
|
|
18605
19098
|
quota: {
|
|
18606
19099
|
type: ["object", "null"],
|
|
18607
19100
|
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)."
|
|
@@ -18623,8 +19116,7 @@ var init_account_status = __esm({
|
|
|
18623
19116
|
_meta: {
|
|
18624
19117
|
type: "object",
|
|
18625
19118
|
properties: {
|
|
18626
|
-
region: { type: "string" }
|
|
18627
|
-
agent_memory: { type: "object" }
|
|
19119
|
+
region: { type: "string" }
|
|
18628
19120
|
}
|
|
18629
19121
|
},
|
|
18630
19122
|
// Set by the MCP server wrapper (NOT this composite) on every call.
|
|
@@ -18658,6 +19150,7 @@ var init_account_status = __esm({
|
|
|
18658
19150
|
},
|
|
18659
19151
|
execute: async (client, _params, ctx) => {
|
|
18660
19152
|
const me = await client.resolveMe();
|
|
19153
|
+
const searchConfiguration = readSearchConfiguration(client, me.organization.id, ctx);
|
|
18661
19154
|
let quota = null;
|
|
18662
19155
|
let quota_error = null;
|
|
18663
19156
|
try {
|
|
@@ -18713,6 +19206,7 @@ var init_account_status = __esm({
|
|
|
18713
19206
|
// schema and never drifts string-vs-number across accounts.
|
|
18714
19207
|
last_requested_lens: lensAsked && lensId != null ? String(lensId) : null,
|
|
18715
19208
|
last_requested_lens_name,
|
|
19209
|
+
search_configuration: await searchConfiguration,
|
|
18716
19210
|
// Quota goes here verbatim from /quota_status. Legacy freemium.* fields
|
|
18717
19211
|
// on /me are intentionally NOT surfaced — they're defunct (see
|
|
18718
19212
|
// SHAPE-DRIFT.md probe round 4).
|
|
@@ -20589,6 +21083,23 @@ var init_qualify_status = __esm({
|
|
|
20589
21083
|
});
|
|
20590
21084
|
|
|
20591
21085
|
// ../core/dist/composite/enrich-titles.js
|
|
21086
|
+
async function selectLeads2(client, leadIds) {
|
|
21087
|
+
let unknownBatch = null;
|
|
21088
|
+
let anySelected = false;
|
|
21089
|
+
for (let i = 0; i < leadIds.length; i += SELECT_BATCH_SIZE) {
|
|
21090
|
+
const qs = leadIds.slice(i, i + SELECT_BATCH_SIZE).map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
|
|
21091
|
+
try {
|
|
21092
|
+
await client.requestVoid("POST", `/leads/selection/select?${qs}`);
|
|
21093
|
+
anySelected = true;
|
|
21094
|
+
} catch (err) {
|
|
21095
|
+
if (!/no known leads/.test(String(err?.message)))
|
|
21096
|
+
throw err;
|
|
21097
|
+
unknownBatch = err;
|
|
21098
|
+
}
|
|
21099
|
+
}
|
|
21100
|
+
if (!anySelected && unknownBatch)
|
|
21101
|
+
throw unknownBatch;
|
|
21102
|
+
}
|
|
20592
21103
|
async function launchOnSelection(client, args, ctx) {
|
|
20593
21104
|
const { leadIds, titles, email, phone, lensId, selectionSource, preview } = args;
|
|
20594
21105
|
const fingerprint = launchFingerprint([
|
|
@@ -20670,9 +21181,8 @@ async function launchOnSelection(client, args, ctx) {
|
|
|
20670
21181
|
async function launchEnrichment(client, args, ctx) {
|
|
20671
21182
|
await client.acquireSelectionLock();
|
|
20672
21183
|
try {
|
|
20673
|
-
const qs = args.leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
|
|
20674
|
-
await client.requestVoid("POST", `/leads/selection/select?${qs}`);
|
|
20675
21184
|
try {
|
|
21185
|
+
await selectLeads2(client, args.leadIds);
|
|
20676
21186
|
return await launchOnSelection(client, args, ctx);
|
|
20677
21187
|
} finally {
|
|
20678
21188
|
try {
|
|
@@ -20685,7 +21195,7 @@ async function launchEnrichment(client, args, ctx) {
|
|
|
20685
21195
|
client.releaseSelectionLock();
|
|
20686
21196
|
}
|
|
20687
21197
|
}
|
|
20688
|
-
var DEFAULT_CANDIDATE_COUNT, enrichTitles;
|
|
21198
|
+
var DEFAULT_CANDIDATE_COUNT, SELECT_BATCH_SIZE, enrichTitles;
|
|
20689
21199
|
var init_enrich_titles = __esm({
|
|
20690
21200
|
"../core/dist/composite/enrich-titles.js"() {
|
|
20691
21201
|
"use strict";
|
|
@@ -20693,6 +21203,7 @@ var init_enrich_titles = __esm({
|
|
|
20693
21203
|
init_credits_helpers();
|
|
20694
21204
|
init_tool_descriptions_generated();
|
|
20695
21205
|
DEFAULT_CANDIDATE_COUNT = 25;
|
|
21206
|
+
SELECT_BATCH_SIZE = 100;
|
|
20696
21207
|
enrichTitles = {
|
|
20697
21208
|
name: "leadbay_enrich_titles",
|
|
20698
21209
|
annotations: {
|
|
@@ -20778,6 +21289,10 @@ var init_enrich_titles = __esm({
|
|
|
20778
21289
|
type: "number",
|
|
20779
21290
|
description: "Count of enrichable contacts at preview time."
|
|
20780
21291
|
},
|
|
21292
|
+
already_enriched_contacts: {
|
|
21293
|
+
type: "number",
|
|
21294
|
+
description: "preview_only mode: contacts with these titles on the selected leads that your organization already enriched. Above 0 means the work is done, not that nobody has these titles."
|
|
21295
|
+
},
|
|
20781
21296
|
credits_remaining: {
|
|
20782
21297
|
type: ["number", "string", "null"],
|
|
20783
21298
|
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.`
|
|
@@ -20882,9 +21397,8 @@ var init_enrich_titles = __esm({
|
|
|
20882
21397
|
let outcome;
|
|
20883
21398
|
await client.acquireSelectionLock();
|
|
20884
21399
|
try {
|
|
20885
|
-
const qs = leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
|
|
20886
|
-
await client.requestVoid("POST", `/leads/selection/select?${qs}`);
|
|
20887
21400
|
try {
|
|
21401
|
+
await selectLeads2(client, leadIds);
|
|
20888
21402
|
ctx?.progress?.({
|
|
20889
21403
|
progress: 2,
|
|
20890
21404
|
total: 3,
|
|
@@ -20937,13 +21451,18 @@ var init_enrich_titles = __esm({
|
|
|
20937
21451
|
throw err;
|
|
20938
21452
|
}
|
|
20939
21453
|
if (preview2.enrichable_contacts === 0) {
|
|
21454
|
+
const alreadyEnriched = preview2.enriched_contacts;
|
|
20940
21455
|
outcome = {
|
|
20941
21456
|
kind: "terminal",
|
|
20942
21457
|
result: {
|
|
20943
21458
|
mode: "preview_only",
|
|
20944
21459
|
preview: preview2,
|
|
20945
21460
|
launched: false,
|
|
20946
|
-
|
|
21461
|
+
already_enriched_contacts: alreadyEnriched,
|
|
21462
|
+
message: alreadyEnriched > 0 ? `All ${alreadyEnriched} contacts with these titles on these leads were already enriched for this organization, or are being enriched now. Nothing was launched or charged. Calling again with the same titles and leads returns this same answer until new contacts are added to these leads.` : "No enrichable contacts for the chosen titles. Try other titles from available_titles or recommendations.",
|
|
21463
|
+
...alreadyEnriched > 0 ? {
|
|
21464
|
+
next_action: "Read each lead's contacts with leadbay_research_lead_by_id and report what is there. A finished enrichment that returned no email or phone found none, and repeating it will not change that. Do not call leadbay_enrich_titles again for these leads and titles; pick another title only if the user wants a different person."
|
|
21465
|
+
} : {},
|
|
20947
21466
|
available_titles: availableTitles2,
|
|
20948
21467
|
credits_remaining: await readCreditsRemaining(client)
|
|
20949
21468
|
}
|
|
@@ -21827,7 +22346,7 @@ var init_adjust_audience = __esm({
|
|
|
21827
22346
|
const lensRef = params.lensId !== void 0 ? String(params.lensId) : "<the lens being edited>";
|
|
21828
22347
|
return {
|
|
21829
22348
|
...envelope,
|
|
21830
|
-
hint: `${envelope.hint} Before that re-call, read \`lens://${lensRef}/definition\` \u2014 location criteria MERGE here rather than replace, so any geography the lens already carries survives the re-call untouched. \`leadbay_pull_leads\` returns only \`lens: {id}\` and
|
|
22349
|
+
hint: `${envelope.hint} Before that re-call, read lens ${lensRef}'s \`criteria\` in \`leadbay_my_lenses\` (or the \`lens://${lensRef}/definition\` resource on hosts that expose one) \u2014 location criteria MERGE here rather than replace, so any geography the lens already carries survives the re-call untouched. \`leadbay_pull_leads\` returns only \`lens: {id}\` and no filter, so it cannot tell you what that geography is. If the lens is already scoped to a place, the edited audience stays scoped to it: say which places it actually covers, or clear those criteria first if whole-workspace is what was meant.`
|
|
21831
22350
|
};
|
|
21832
22351
|
}
|
|
21833
22352
|
const me = await client.resolveMe();
|
|
@@ -22272,7 +22791,7 @@ var init_seed_candidates = __esm({
|
|
|
22272
22791
|
});
|
|
22273
22792
|
|
|
22274
22793
|
// ../core/dist/composite/_mcp-job-helpers.js
|
|
22275
|
-
import { createHash as
|
|
22794
|
+
import { createHash as createHash5 } from "crypto";
|
|
22276
22795
|
function assertSafeJobId(jobId) {
|
|
22277
22796
|
const reject = (why) => {
|
|
22278
22797
|
throw {
|
|
@@ -22567,7 +23086,7 @@ function canonicalize(value) {
|
|
|
22567
23086
|
}
|
|
22568
23087
|
function derivedKey(prefix, shape) {
|
|
22569
23088
|
const serialized = typeof shape === "string" ? shape : JSON.stringify(canonicalize(shape));
|
|
22570
|
-
return `${prefix}-${
|
|
23089
|
+
return `${prefix}-${createHash5("sha256").update(serialized).digest("hex").slice(0, 32)}`;
|
|
22571
23090
|
}
|
|
22572
23091
|
function mockedSubmitPreview(submit, tool, region) {
|
|
22573
23092
|
const s = submit ?? {};
|
|
@@ -22966,6 +23485,63 @@ function sectorChoice(fix) {
|
|
|
22966
23485
|
hint: `No sector in this workspace's taxonomy is named ${names || "that"}. Nothing was submitted and nothing was spent. Re-call with a label copied character for character from \`closest\` or \`sector_sections\` (a section covers everything under it), or drop \`filters.sectors\` and put the wording in \`query\` / \`example_lead.description\` instead \u2014 the search then RANKS on it rather than fencing on it, which is usually what the user meant. \`leadbay_list_sectors\` returns the full taxonomy if none of these fit.`
|
|
22967
23486
|
};
|
|
22968
23487
|
}
|
|
23488
|
+
function buildFindNewLeadsNextSteps(delivered, stillRunning, stopReason, truncated, jobId) {
|
|
23489
|
+
const options = [];
|
|
23490
|
+
if (stillRunning) {
|
|
23491
|
+
options.push({
|
|
23492
|
+
label: "Check progress",
|
|
23493
|
+
description: "Check on the job in about a minute.",
|
|
23494
|
+
kind: "pull_next_page"
|
|
23495
|
+
});
|
|
23496
|
+
if (delivered > 0) {
|
|
23497
|
+
options.push({
|
|
23498
|
+
label: "Triage what landed",
|
|
23499
|
+
description: "Build an interactive lead triage board from the leads delivered so far.",
|
|
23500
|
+
kind: "build_artifact"
|
|
23501
|
+
});
|
|
23502
|
+
}
|
|
23503
|
+
return options.length ? { question: "What do you want to do next?", options: options.slice(0, 4) } : null;
|
|
23504
|
+
}
|
|
23505
|
+
if (delivered === 0) {
|
|
23506
|
+
options.push({
|
|
23507
|
+
label: "Reshape and retry",
|
|
23508
|
+
description: "Adjust the example profile and run the search again.",
|
|
23509
|
+
kind: "refine_audience"
|
|
23510
|
+
});
|
|
23511
|
+
return { question: "What do you want to do next?", options };
|
|
23512
|
+
}
|
|
23513
|
+
options.push({
|
|
23514
|
+
label: "Triage board",
|
|
23515
|
+
description: "Build an interactive lead triage board to sort and filter this batch.",
|
|
23516
|
+
kind: "build_artifact"
|
|
23517
|
+
});
|
|
23518
|
+
if (stopReason === "max_cost") {
|
|
23519
|
+
options.push({
|
|
23520
|
+
label: "Raise the cap",
|
|
23521
|
+
description: "Raise the job's cost cap and fetch the leads it did not reach.",
|
|
23522
|
+
kind: "refine_audience"
|
|
23523
|
+
});
|
|
23524
|
+
} else if (stopReason === "quota") {
|
|
23525
|
+
options.push({
|
|
23526
|
+
label: "Check quota",
|
|
23527
|
+
description: "See which quota window is exhausted and when it resets.",
|
|
23528
|
+
kind: "refine_audience"
|
|
23529
|
+
});
|
|
23530
|
+
}
|
|
23531
|
+
if (truncated && jobId) {
|
|
23532
|
+
options.push({
|
|
23533
|
+
label: "Fetch the rest",
|
|
23534
|
+
description: "Fetch the remaining delivered rows for this job.",
|
|
23535
|
+
kind: "pull_next_page"
|
|
23536
|
+
});
|
|
23537
|
+
}
|
|
23538
|
+
options.push({
|
|
23539
|
+
label: "Qualify these",
|
|
23540
|
+
description: "Score these leads against your qualification criteria.",
|
|
23541
|
+
kind: "qualify_deeper"
|
|
23542
|
+
});
|
|
23543
|
+
return { question: "What do you want to do next?", options: options.slice(0, 4) };
|
|
23544
|
+
}
|
|
22969
23545
|
var DEFAULT_WAIT_SECONDS, findNewLeads;
|
|
22970
23546
|
var init_find_new_leads = __esm({
|
|
22971
23547
|
"../core/dist/composite/find-new-leads.js"() {
|
|
@@ -23266,6 +23842,7 @@ var init_find_new_leads = __esm({
|
|
|
23266
23842
|
estimated_cost: submit.estimated_cost,
|
|
23267
23843
|
explain: snapshot.explain,
|
|
23268
23844
|
still_running: !done,
|
|
23845
|
+
next_steps: buildFindNewLeadsNextSteps(snapshot.funnel.delivered ?? 0, !done, snapshot.funnel.stop_reason ?? null, snapshot.items_truncated ?? false, submit.job_id ?? null),
|
|
23269
23846
|
// A finished job can still owe rows: truncation means the drain stopped
|
|
23270
23847
|
// early, so there is a follow-up action even when done is true. It is a
|
|
23271
23848
|
// page fetch, not a wait, hence suggested_wait_seconds 0.
|
|
@@ -24096,26 +24673,69 @@ var init_extend_lens = __esm({
|
|
|
24096
24673
|
});
|
|
24097
24674
|
|
|
24098
24675
|
// ../core/dist/composite/my-lenses.js
|
|
24099
|
-
async function listWithActive(client) {
|
|
24676
|
+
async function listWithActive(client, ctx, earlier) {
|
|
24100
24677
|
const lenses = await client.request("GET", "/lenses");
|
|
24101
24678
|
const me = await client.resolveMe().catch(() => null);
|
|
24102
24679
|
const activeFromMe = sid(me?.last_requested_lens);
|
|
24103
24680
|
const active_lens_id = activeFromMe ?? sid(lenses.find((l) => l.is_last_active)?.id) ?? null;
|
|
24681
|
+
const known = new Map(earlier?.map((l) => [l.id, l.criteria]));
|
|
24682
|
+
const unread = lenses.map((l) => sid(l.id)).filter((id) => !known.has(id));
|
|
24683
|
+
const read = await readLensCriteria(client, unread, ctx);
|
|
24104
24684
|
return {
|
|
24105
24685
|
active_lens_id,
|
|
24106
|
-
|
|
24107
|
-
|
|
24108
|
-
|
|
24109
|
-
|
|
24110
|
-
|
|
24111
|
-
|
|
24112
|
-
|
|
24686
|
+
// Every field GET /lenses returns, except the two this replaces:
|
|
24687
|
+
// is_last_active (can be stale; is_active is resolved from /me) and
|
|
24688
|
+
// default (folded into is_default).
|
|
24689
|
+
lenses: lenses.map(({ is_last_active: _stale, default: _default, ...l }) => {
|
|
24690
|
+
const id = sid(l.id);
|
|
24691
|
+
return {
|
|
24692
|
+
...l,
|
|
24693
|
+
id,
|
|
24694
|
+
description: l.description ?? null,
|
|
24695
|
+
is_active: id === active_lens_id,
|
|
24696
|
+
is_default: l.is_default === true || _default === true,
|
|
24697
|
+
criteria: known.get(id) ?? read.get(id) ?? null
|
|
24698
|
+
};
|
|
24699
|
+
})
|
|
24113
24700
|
};
|
|
24114
24701
|
}
|
|
24702
|
+
async function readLensCriteria(client, lensIds, ctx) {
|
|
24703
|
+
const filters = await Promise.all(lensIds.map((id) => client.request("GET", `/lenses/${id}/filter`).catch(() => null)));
|
|
24704
|
+
const sectorNames = /* @__PURE__ */ new Map();
|
|
24705
|
+
if (filters.some((f) => f && criteriaOf(f).some((c) => c.type === "sector_ids"))) {
|
|
24706
|
+
const taxonomy = await fetchSectorTaxonomy(client, ctx).catch(() => []);
|
|
24707
|
+
for (const row of taxonomy) {
|
|
24708
|
+
const label = sectorLabel(row);
|
|
24709
|
+
if (label)
|
|
24710
|
+
sectorNames.set(String(row.id), label);
|
|
24711
|
+
}
|
|
24712
|
+
}
|
|
24713
|
+
const named = (ids, names) => (ids ?? []).map((id) => ({ id, name: names.get(String(id)) ?? null }));
|
|
24714
|
+
const out = /* @__PURE__ */ new Map();
|
|
24715
|
+
filters.forEach((filter, i) => {
|
|
24716
|
+
if (!filter)
|
|
24717
|
+
return;
|
|
24718
|
+
const locationNames = /* @__PURE__ */ new Map();
|
|
24719
|
+
for (const r of filter.locations?.results ?? []) {
|
|
24720
|
+
if (typeof r.id === "string" && typeof r.name === "string")
|
|
24721
|
+
locationNames.set(r.id, r.name);
|
|
24722
|
+
}
|
|
24723
|
+
out.set(lensIds[i], criteriaOf(filter).map((c) => {
|
|
24724
|
+
if (c.type === "sector_ids")
|
|
24725
|
+
return { ...c, sectors: named(c.sectors, sectorNames) };
|
|
24726
|
+
if (c.type === "location_ids")
|
|
24727
|
+
return { ...c, locations: named(c.locations, locationNames) };
|
|
24728
|
+
return c;
|
|
24729
|
+
}));
|
|
24730
|
+
});
|
|
24731
|
+
return out;
|
|
24732
|
+
}
|
|
24115
24733
|
var sid, myLenses;
|
|
24116
24734
|
var init_my_lenses = __esm({
|
|
24117
24735
|
"../core/dist/composite/my-lenses.js"() {
|
|
24118
24736
|
"use strict";
|
|
24737
|
+
init_empty_lens_reason();
|
|
24738
|
+
init_sector_resolver();
|
|
24119
24739
|
init_tool_descriptions_generated();
|
|
24120
24740
|
sid = (v) => v == null ? null : String(v);
|
|
24121
24741
|
myLenses = {
|
|
@@ -24180,17 +24800,17 @@ var init_my_lenses = __esm({
|
|
|
24180
24800
|
active_lens_id: { type: ["string", "null"] },
|
|
24181
24801
|
lenses: {
|
|
24182
24802
|
type: "array",
|
|
24183
|
-
description: "The user's lenses
|
|
24803
|
+
description: "The user's lenses, each with every field GET /lenses returns plus is_active, is_default and `criteria` (its sectors, locations and sizes by name; null if unreadable).",
|
|
24184
24804
|
items: { type: "object" }
|
|
24185
24805
|
},
|
|
24186
24806
|
message: { type: "string" }
|
|
24187
24807
|
},
|
|
24188
24808
|
required: ["status", "lenses", "active_lens_id"]
|
|
24189
24809
|
},
|
|
24190
|
-
execute: async (client, params) => {
|
|
24810
|
+
execute: async (client, params, ctx) => {
|
|
24191
24811
|
if (params.deleteLensId != null) {
|
|
24192
24812
|
const targetId = sid(params.deleteLensId);
|
|
24193
|
-
const before = await listWithActive(client);
|
|
24813
|
+
const before = await listWithActive(client, ctx);
|
|
24194
24814
|
const target = before.lenses.find((l) => l.id === targetId);
|
|
24195
24815
|
if (!target) {
|
|
24196
24816
|
return {
|
|
@@ -24229,7 +24849,7 @@ var init_my_lenses = __esm({
|
|
|
24229
24849
|
await client.requestVoid("DELETE", `/lenses/${targetId}`);
|
|
24230
24850
|
client.invalidateMe();
|
|
24231
24851
|
client.invalidateDefaultLens();
|
|
24232
|
-
const after = await listWithActive(client);
|
|
24852
|
+
const after = await listWithActive(client, ctx, before.lenses);
|
|
24233
24853
|
return {
|
|
24234
24854
|
status: "deleted",
|
|
24235
24855
|
switched: false,
|
|
@@ -24242,7 +24862,7 @@ var init_my_lenses = __esm({
|
|
|
24242
24862
|
}
|
|
24243
24863
|
if (params.editLensId != null) {
|
|
24244
24864
|
const targetId = sid(params.editLensId);
|
|
24245
|
-
const before = await listWithActive(client);
|
|
24865
|
+
const before = await listWithActive(client, ctx);
|
|
24246
24866
|
const target = before.lenses.find((l) => l.id === targetId);
|
|
24247
24867
|
if (!target) {
|
|
24248
24868
|
return {
|
|
@@ -24276,7 +24896,7 @@ var init_my_lenses = __esm({
|
|
|
24276
24896
|
body.name != null ? `renamed to "${body.name}"` : null,
|
|
24277
24897
|
body.description !== void 0 ? "description updated" : null
|
|
24278
24898
|
].filter(Boolean).join(", ");
|
|
24279
|
-
const after = await listWithActive(client);
|
|
24899
|
+
const after = await listWithActive(client, ctx, before.lenses);
|
|
24280
24900
|
return {
|
|
24281
24901
|
status: "edited",
|
|
24282
24902
|
switched: false,
|
|
@@ -24288,7 +24908,7 @@ var init_my_lenses = __esm({
|
|
|
24288
24908
|
}
|
|
24289
24909
|
if (params.switchToLensId != null) {
|
|
24290
24910
|
const targetId = sid(params.switchToLensId);
|
|
24291
|
-
const before = await listWithActive(client);
|
|
24911
|
+
const before = await listWithActive(client, ctx);
|
|
24292
24912
|
const target = before.lenses.find((l) => l.id === targetId);
|
|
24293
24913
|
if (!target) {
|
|
24294
24914
|
return {
|
|
@@ -24313,7 +24933,7 @@ var init_my_lenses = __esm({
|
|
|
24313
24933
|
await client.requestVoid("POST", `/lenses/${targetId}/update_last_requested`);
|
|
24314
24934
|
client.invalidateMe();
|
|
24315
24935
|
client.invalidateDefaultLens();
|
|
24316
|
-
const after = await listWithActive(client);
|
|
24936
|
+
const after = await listWithActive(client, ctx, before.lenses);
|
|
24317
24937
|
return {
|
|
24318
24938
|
status: "switched",
|
|
24319
24939
|
switched: true,
|
|
@@ -24323,7 +24943,7 @@ var init_my_lenses = __esm({
|
|
|
24323
24943
|
message: `Now showing "${target.name}".`
|
|
24324
24944
|
};
|
|
24325
24945
|
}
|
|
24326
|
-
const { lenses, active_lens_id } = await listWithActive(client);
|
|
24946
|
+
const { lenses, active_lens_id } = await listWithActive(client, ctx);
|
|
24327
24947
|
return { status: "listed", switched: false, edited: false, active_lens_id, lenses };
|
|
24328
24948
|
}
|
|
24329
24949
|
};
|
|
@@ -24452,9 +25072,10 @@ var init_new_lens = __esm({
|
|
|
24452
25072
|
const authorizesReCall = /re-call ONCE/.test(envelope.hint);
|
|
24453
25073
|
if (!authorizesReCall)
|
|
24454
25074
|
return envelope;
|
|
25075
|
+
const baseRef = params.base ?? "<active lens id>";
|
|
24455
25076
|
return {
|
|
24456
25077
|
...envelope,
|
|
24457
|
-
hint: `${envelope.hint} Before that re-call, read the geography of the lens being cloned \u2014
|
|
25078
|
+
hint: `${envelope.hint} Before that re-call, read the geography of the lens being cloned \u2014 lens ${baseRef}'s \`criteria\` in \`leadbay_my_lenses\` names its \`location_ids\` (so does the \`lens://${baseRef}/definition\` resource on hosts that expose one; \`leadbay_pull_leads\` returns only \`lens: {id}\`, so it cannot). A clone INHERITS that geography, so if the base carries any, the new lens is scoped to it no matter that no location was passed \u2014 and calling the result whole-workspace would be false. If it does carry geography, either clear it on the new lens or say plainly which places it actually covers.`
|
|
24458
25079
|
};
|
|
24459
25080
|
}
|
|
24460
25081
|
const includeRes = await resolveSectors(client, params.sectors ?? [], ctx);
|
|
@@ -24668,6 +25289,13 @@ function formatNoteWithVerification(note, v) {
|
|
|
24668
25289
|
|
|
24669
25290
|
\u2014 logged by AI agent (verification: ${v.source}=${v.ref})`;
|
|
24670
25291
|
}
|
|
25292
|
+
async function alreadyLogged(client, notesPath, v) {
|
|
25293
|
+
if (v.source === "user_confirmed")
|
|
25294
|
+
return false;
|
|
25295
|
+
const line = `(verification: ${v.source}=${v.ref})`;
|
|
25296
|
+
const notes = await client.request("GET", notesPath).catch(() => null);
|
|
25297
|
+
return (Array.isArray(notes) ? notes : []).some((n) => typeof n?.note === "string" && n.note.trimEnd().endsWith(line));
|
|
25298
|
+
}
|
|
24671
25299
|
var VALID_SOURCES, reportOutreach;
|
|
24672
25300
|
var init_report_outreach = __esm({
|
|
24673
25301
|
"../core/dist/composite/report-outreach.js"() {
|
|
@@ -24700,6 +25328,10 @@ var init_report_outreach = __esm({
|
|
|
24700
25328
|
items: { type: "string" },
|
|
24701
25329
|
description: "Bulk: many lead UUIDs (epilogue applies to all; notes fan out)"
|
|
24702
25330
|
},
|
|
25331
|
+
contact_id: {
|
|
25332
|
+
type: "string",
|
|
25333
|
+
description: "Optional, with lead_id only: the org contact id of the person the outreach went to. The note and the epilogue status are then written on that person, and still show on the lead's Monitor row."
|
|
25334
|
+
},
|
|
24703
25335
|
note: {
|
|
24704
25336
|
type: "string",
|
|
24705
25337
|
description: "1-2 sentence summary of what was done (e.g. 'Sent intro email to CTO citing Hornsea 3 contract')"
|
|
@@ -24777,6 +25409,15 @@ var init_report_outreach = __esm({
|
|
|
24777
25409
|
ref: { type: "string" }
|
|
24778
25410
|
}
|
|
24779
25411
|
},
|
|
25412
|
+
contact_id: {
|
|
25413
|
+
type: "string",
|
|
25414
|
+
description: "Present when the note and the status were written on this person rather than on the lead."
|
|
25415
|
+
},
|
|
25416
|
+
already_logged: {
|
|
25417
|
+
type: "array",
|
|
25418
|
+
items: { type: "string" },
|
|
25419
|
+
description: "Lead ids skipped because a note with this exact gmail_message_id or calendar_event_id is already there. Nothing was written for them."
|
|
25420
|
+
},
|
|
24780
25421
|
confirmed_via: {
|
|
24781
25422
|
type: "string",
|
|
24782
25423
|
description: "Audit trail of how verification was obtained: 'elicit' (user typed into client UI \u2014 anti-poisoning), 'agent_supplied' (legacy path; user_confirmed source with no elicit), 'non_user_confirmed' (gmail_message_id or calendar_event_id \u2014 agent can't fabricate these)."
|
|
@@ -24815,6 +25456,14 @@ var init_report_outreach = __esm({
|
|
|
24815
25456
|
hint: "Use 'user_confirmed' with verification.ref set to the user's literal text if you don't have a Gmail/Calendar id"
|
|
24816
25457
|
};
|
|
24817
25458
|
}
|
|
25459
|
+
if (params.contact_id && (!params.lead_id || params.lead_ids)) {
|
|
25460
|
+
return {
|
|
25461
|
+
error: true,
|
|
25462
|
+
code: "BAD_INPUT",
|
|
25463
|
+
message: "contact_id goes with a single lead_id, not lead_ids",
|
|
25464
|
+
hint: "Call once per person: lead_id plus that person's contact_id."
|
|
25465
|
+
};
|
|
25466
|
+
}
|
|
24818
25467
|
if (!params.lead_id && (!params.lead_ids || params.lead_ids.length === 0)) {
|
|
24819
25468
|
return {
|
|
24820
25469
|
error: true,
|
|
@@ -24886,25 +25535,34 @@ var init_report_outreach = __esm({
|
|
|
24886
25535
|
}
|
|
24887
25536
|
epilogueWire = w;
|
|
24888
25537
|
}
|
|
24889
|
-
const
|
|
25538
|
+
const contactId = params.contact_id;
|
|
25539
|
+
const notesPath = (leadId) => contactId ? `/contacts/${contactId}/notes` : `/leads/${leadId}/notes`;
|
|
24890
25540
|
if (params.dry_run) {
|
|
24891
25541
|
return {
|
|
24892
25542
|
dry_run: true,
|
|
24893
|
-
would_write_notes:
|
|
25543
|
+
would_write_notes: (params.lead_ids ?? [params.lead_id]).map((id) => ({
|
|
24894
25544
|
method: "POST",
|
|
24895
|
-
path:
|
|
25545
|
+
path: notesPath(id),
|
|
24896
25546
|
body: { note: noteBody }
|
|
24897
25547
|
})),
|
|
24898
|
-
would_set_epilogue: epilogueWire ? {
|
|
25548
|
+
would_set_epilogue: epilogueWire ? contactId ? {
|
|
25549
|
+
method: "PATCH",
|
|
25550
|
+
path: `/contacts/${contactId}/prospecting-status`,
|
|
25551
|
+
body: { status: epilogueWire }
|
|
25552
|
+
} : {
|
|
24899
25553
|
method: "POST",
|
|
24900
25554
|
path: "/leads/epilogue",
|
|
24901
|
-
body: { lead_ids:
|
|
25555
|
+
body: { lead_ids: params.lead_ids ?? [params.lead_id], status: epilogueWire }
|
|
24902
25556
|
} : null
|
|
24903
25557
|
};
|
|
24904
25558
|
}
|
|
25559
|
+
const requested = params.lead_ids ?? [params.lead_id];
|
|
25560
|
+
const logged = await Promise.all(requested.map((id) => alreadyLogged(client, notesPath(id), effectiveVerification)));
|
|
25561
|
+
const alreadyLoggedLeads = requested.filter((_, i) => logged[i]);
|
|
25562
|
+
const targetLeads = requested.filter((_, i) => !logged[i]);
|
|
24905
25563
|
const noteResults = await Promise.all(targetLeads.map(async (leadId) => {
|
|
24906
25564
|
try {
|
|
24907
|
-
const note = await client.request("POST",
|
|
25565
|
+
const note = await client.request("POST", notesPath(leadId), { note: noteBody });
|
|
24908
25566
|
return { lead_id: leadId, ok: true, note_id: note.id };
|
|
24909
25567
|
} catch (err) {
|
|
24910
25568
|
return {
|
|
@@ -24915,12 +25573,18 @@ var init_report_outreach = __esm({
|
|
|
24915
25573
|
}
|
|
24916
25574
|
}));
|
|
24917
25575
|
let epilogueResult = { applied: false };
|
|
24918
|
-
if (epilogueWire) {
|
|
25576
|
+
if (epilogueWire && targetLeads.length > 0) {
|
|
24919
25577
|
try {
|
|
24920
|
-
|
|
24921
|
-
|
|
24922
|
-
|
|
24923
|
-
|
|
25578
|
+
if (contactId) {
|
|
25579
|
+
await client.requestVoid("PATCH", `/contacts/${contactId}/prospecting-status`, {
|
|
25580
|
+
status: epilogueWire
|
|
25581
|
+
});
|
|
25582
|
+
} else {
|
|
25583
|
+
await client.requestVoid("POST", "/leads/epilogue", {
|
|
25584
|
+
lead_ids: targetLeads,
|
|
25585
|
+
status: epilogueWire
|
|
25586
|
+
});
|
|
25587
|
+
}
|
|
24924
25588
|
epilogueResult = { applied: true };
|
|
24925
25589
|
} catch (err) {
|
|
24926
25590
|
epilogueResult = {
|
|
@@ -24939,6 +25603,8 @@ var init_report_outreach = __esm({
|
|
|
24939
25603
|
status: epilogueWire,
|
|
24940
25604
|
...epilogueResult
|
|
24941
25605
|
},
|
|
25606
|
+
...contactId ? { contact_id: contactId } : {},
|
|
25607
|
+
...alreadyLoggedLeads.length > 0 ? { already_logged: alreadyLoggedLeads } : {},
|
|
24942
25608
|
verification: effectiveVerification,
|
|
24943
25609
|
// iter-22: audit-trail field. Tells the SDR team which path was taken
|
|
24944
25610
|
// for this call:
|
|
@@ -25325,8 +25991,8 @@ var init_artifact_runtime_generated = __esm({
|
|
|
25325
25991
|
"../core/dist/artifact-runtime.generated.js"() {
|
|
25326
25992
|
"use strict";
|
|
25327
25993
|
ARTIFACT_KIT_VERSION = "0.5.0";
|
|
25328
|
-
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);})();';
|
|
25329
|
-
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.';
|
|
25994
|
+
ARTIFACT_RUNTIME = '"use strict";(()=>{var H=Object.defineProperty;var j=(e,t,r)=>t in e?H(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r;var i=(e,t,r)=>j(e,typeof t!="symbol"?t+"":t,r);var M=`\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/* Purple carries the product\'s "AI" affordance (Qualify / Requalify). The\n border is the foreground at 65% transparency, exactly as the style package\n derives every *-border token. */\n--color-purple-background:oklch(0.947 0.029 288);--color-purple-foreground:oklch(0.464 0.181 288);\n--color-purple-border:color-mix(in oklch,var(--color-purple-foreground),transparent 65%);\n/* Teal carries the qualifier\'s intent tags, as the product\'s taste-profile\n tags do. Border derived at 65% transparency, like every *-border token. */\n--color-teal-background:oklch(0.977 0.019 180);--color-teal-foreground:oklch(0.574 0.182 180);\n--color-teal-border:color-mix(in oklch,var(--color-teal-foreground),transparent 65%);\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). The card\'s\n padding is what closes this equation, so the two move together \\u2014 see\n .lb-card, which pads 0.875rem for exactly this reason. */\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/* macOS renders text heavier than intended. This belongs once on the root,\n never per component \\u2014 an artifact that injects this sheet gets it for free\n instead of each page re-declaring it on its own body. */\n-webkit-font-smoothing:antialiased;\n-moz-osx-font-smoothing:grayscale;\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--color-purple-background:oklch(0.30 0.055 288);--color-purple-foreground:oklch(0.82 0.11 288);\n--color-teal-background:oklch(0.30 0.055 180);--color-teal-foreground:oklch(0.82 0.11 180);\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--color-purple-background:oklch(0.30 0.055 288);--color-purple-foreground:oklch(0.82 0.11 288);\n--color-teal-background:oklch(0.30 0.055 180);--color-teal-foreground:oklch(0.82 0.11 180);\n}}\n/* 0.875rem padding is not arbitrary: it closes the concentric equation with\n --lb-radius (1.5rem) and --lb-radius-sm (0.625rem). Changing it without\n moving --lb-radius leaves a control\'s arc running wider than the card\'s in\n the corner. */\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/* \\u2500\\u2500 Card layout \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n A lead card is a stack of SECTIONS, each a titled block (Fit, Intent tags,\n Data, Status, Outreach). One wrapper owns the rhythm \\u2014 16px between\n sections, 8px between the items inside one \\u2014 so a section can be added,\n removed or reordered without touching spacing, and no margin can collapse.\n Every artifact that builds cards gets the same geometry from here rather\n than hand-rolling it. */\n.lb-sections{display:flex;flex-direction:column;gap:1rem}\n.lb-section{display:grid;gap:0.5rem}\n/* A section title: black, uppercase, tracked. It names the block below it, so\n it must sit at SECTION level \\u2014 nested inside a row it stops being a peer of\n the other titles and the card loses its scan order. */\n.lb-sec-title{font-size:0.75rem;font-weight:700;letter-spacing:.08em;\ntext-transform:uppercase;color:var(--lb-fg);line-height:1.4;\ntext-wrap:balance}\n/* Header row: leading control (a bulk checkbox), the title, then trailing\n controls (taste) pushed to the far edge. */\n.lb-card-top{display:flex;align-items:center;gap:0.625rem;flex-wrap:wrap}\n.lb-card-top>.lb-group{flex:0 0 auto;margin-inline-start:auto}\n.lb-card-top>input[type=checkbox]{flex:0 0 auto;width:1rem;height:1rem;margin:0}\n/* The company name. Bold and underlined so it reads as the card\'s subject and\n as a link, without competing with the uppercase section titles. */\n/* text-wrap:balance keeps a two-line company name from breaking one-word-over;\n the underline takes its position and thickness from the font\'s own metrics\n rather than wherever the browser puts them, and skip-ink clears descenders. */\n.lb-lead-title{flex:1 1 9rem;min-width:0;font-size:0.9375rem;font-weight:700;\nline-height:1.3;color:var(--lb-fg);text-wrap:balance;text-decoration:underline;\ntext-underline-position:from-font;text-decoration-thickness:from-font;\ntext-underline-offset:2px;text-decoration-skip-ink:auto;overflow-wrap:break-word}\n.lb-lead-title a{color:inherit;text-decoration:inherit}\n/* Controls in a section stack, each spanning it, so every control on the card\n shares one leading edge and one width. Three different widths across a row\n read as three unrelated things. */\n.lb-section>.lb-select,.lb-section>.lb-input,\n.lb-stack>.lb-select,.lb-stack>.lb-input,.lb-stack>.lb-btn{width:100%;min-width:0}\n/* Footer: a leading action, a spacer, a trailing escape hatch. */\n.lb-card-foot{display:flex;align-items:center;gap:0.625rem;\npadding-block-start:0.875rem;border-block-start:1px solid var(--lb-border)}\n.lb-card-foot .lb-spacer{flex:1 1 auto}\n/* \\u2500\\u2500 Toolbar \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n The bar above a deck of cards: a tally, the controls that narrow what is\n shown, and the controls that act on a selection. Those are three different\n jobs, so they are three groups \\u2014 0.5rem within one, 1.5rem between \\u2014 rather\n than one flat row where "apply to selected" reads as another filter.\n Concentric: it pads 0.75rem around controls that round at 0.625rem, so it\n rounds at 1.375rem. */\n.lb-toolbar{display:flex;flex-wrap:wrap;align-items:flex-end;gap:0.75rem 1.5rem;\npadding:0.75rem 1rem;background-color:var(--lb-surface);\nborder:1px solid var(--lb-border);border-radius:1.375rem;corner-shape:squircle;\nfont-family:var(--lb-font);color:var(--lb-fg)}\n.lb-toolbar .lb-group{gap:0.5rem}\n/* A labelled control stacks: caption above, control below, as a card section\n does. One label pattern across the whole artifact. */\n.lb-field{display:inline-flex;flex-direction:column;align-items:flex-start;\ngap:0.25rem;font-size:0.8125rem;line-height:1.4;color:var(--lb-muted);\nwhite-space:nowrap}\n/* A checkbox belongs BESIDE its words \\u2014 under a caption it reads as orphaned. */\n.lb-field-inline{flex-direction:row;align-items:center;gap:0.5rem}\n/* A control\'s caption is not body copy: it names the control, so it takes the\n same treatment as a card\'s section title. */\n.lb-field-label{font-size:0.75rem;font-weight:700;letter-spacing:.08em;\ntext-transform:uppercase;color:var(--lb-fg)}\n/* A labelled field sizes to its own content, so width:100% on the control\n inside only fills whatever the field already became. Two fields side by side\n still come out unequal \\u2014 they have no shared reference to be equal against.\n Give the FIELD the basis: flex:1 1 9rem makes every labelled field claim the\n same share of the row, so a select and a text input beside it match. */\n.lb-field{flex:1 1 9rem;min-width:9rem;max-width:16rem}\n.lb-field>.lb-select,.lb-field>.lb-input{width:100%;min-width:0}\n/* An inline field holds a checkbox, not a sized control: natural width, no floor. */\n.lb-field-inline{flex:0 0 auto;min-width:0;max-width:none}\n.lb-field-inline>*{min-width:0;width:auto;flex:0 0 auto}\n/* Readouts sit on the control baseline, not the caption\'s. */\n.lb-toolbar .lb-tally,.lb-toolbar .lb-status{align-self:center}\n.lb-tally{font-size:0.8125rem;line-height:1.4;color:var(--lb-muted);\nfont-variant-numeric:tabular-nums;white-space:nowrap}\n/* Connection state, never carried by colour alone: live is a filled dot, dead\n a hollow ring, so the two differ in shape as well as hue \\u2014 a red and a green\n circle are the same circle to a colour-blind reader, and identical under\n forced colours. */\n.lb-status{display:inline-flex;align-items:center;gap:0.5rem;\nfont-size:0.75rem;line-height:1.4;color:var(--lb-muted);white-space:nowrap}\n.lb-status-dot{width:0.5rem;height:0.5rem;border-radius:50%;flex:none;\nbackground-color:var(--color-gray-5);border:2px solid transparent}\n.lb-status[data-live=yes] .lb-status-dot{background-color:var(--color-green-foreground)}\n.lb-status[data-live=no] .lb-status-dot{background-color:transparent;\nborder-color:var(--color-red-foreground)}\n/* \\u2500\\u2500 Pager \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n Below a deck, never above it: the rep reads the rows, then decides whether\n to go on. The range ("21\\u201340 of 60") is the honest label \\u2014 "page 2" alone\n does not say how much is left, and a rep who cannot see the end cannot tell\n a short lens from a long one. Tabular, because the numbers change. */\n.lb-pager{display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap}\n.lb-pager .lb-spacer{flex:1 1 auto}\n.lb-pager-range{font-size:0.8125rem;line-height:1.4;color:var(--lb-muted);\nfont-variant-numeric:tabular-nums;white-space:nowrap}\n/* \\u2500\\u2500 Metrics + chart \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n A manager dashboard reports before it lists: a row of figures, then the\n trend behind them, then the per-rep table. Tiles are for the few numbers\n that ARE the point \\u2014 a tile per field turns a dashboard into a wall. */\n.lb-tiles{display:grid;gap:0.75rem;\ngrid-template-columns:repeat(auto-fit,minmax(min(9rem,100%),1fr))}\n.lb-tile{display:grid;gap:0.25rem;padding:0.75rem 0.875rem;\nbackground-color:var(--lb-field);border:1px solid var(--lb-border);\nborder-radius:1.375rem;corner-shape:squircle}\n.lb-tile-label{font-size:0.75rem;font-weight:700;letter-spacing:.08em;\ntext-transform:uppercase;color:var(--lb-muted);line-height:1.4}\n.lb-tile-value{font-size:1.5rem;font-weight:700;line-height:1.15;\ncolor:var(--lb-fg);font-variant-numeric:tabular-nums}\n/* The chart draws from the theme tokens, so it reads in light and dark alike;\n a hardcoded stroke disappears on one of the two. Inline SVG rather than a\n CDN library: a sparse weekly series does not earn 200KB, and an artifact\n that silently fails to load its chart shows nothing at all. */\n.lb-chart{display:block;width:100%;height:auto}\n.lb-chart .lb-chart-grid{stroke:var(--lb-border);stroke-width:1}\n.lb-chart .lb-chart-line{fill:none;stroke:var(--color-blue-foreground);stroke-width:2;\nstroke-linejoin:round;stroke-linecap:round}\n.lb-chart .lb-chart-area{fill:var(--color-blue-background);opacity:.55}\n.lb-chart .lb-chart-dot{fill:var(--color-blue-foreground)}\n/* Axis labels are UI text and take the same 12px floor as everything else \\u2014\n a chart is not a licence to shrink type below what is readable. */\n.lb-chart text{fill:var(--lb-muted);font-family:var(--lb-font);font-size:0.75rem}\n/* An empty series is a real answer, not a failure: say so in place of the\n chart rather than drawing empty axes that look broken. */\n.lb-empty{display:grid;place-items:center;gap:0.25rem;padding:1.5rem 1rem;\nbackground-color:var(--lb-field);border:1px dashed var(--lb-border);\nborder-radius:1.375rem;corner-shape:squircle;text-align:center}\n.lb-empty-title{font-size:0.8125rem;font-weight:600;color:var(--lb-fg);line-height:1.4}\n.lb-empty-hint{font-size:0.78125rem;color:var(--lb-muted);line-height:1.45;max-width:44ch}\n/* A page flip replaces the whole deck, so the rows must read as pending or the\n rep cannot tell a slow page from a finished one. Dim and lock the deck\n rather than blanking it: keeping the old rows on screen preserves the\n scroll position and the sense of place. */\n.lb-deck[data-lb-state=loading]{opacity:.55;pointer-events:none}\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/* pretty stops a lone word landing on the last line of a why-it-fits sentence\n or a fact \\u2014 the orphan that makes a card look unfinished. */\n.lb-sub{font-size:0.8125rem;line-height:1.45;color:var(--lb-muted);\ntext-wrap:pretty;overflow-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/* The tightest group on the card: consecutive data lines (the contact, then\n the company switchboard) are one block, so they sit at HALF the section\'s\n own 8px. That 2x ratio is what makes them read as a unit rather than as\n separate facts \\u2014 the same rule .lb-section applies one level up against\n .lb-sections\' 16px. The scale halves at each level: 16 \\u2192 8 \\u2192 4. */\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/* 8px, matching .lb-section \\u2014 a stack inside a section must not space its\n controls wider than the section spaces its own children. */\n.lb-stack{display:grid;gap:0.5rem}\n/* A select is border-box by UA default; a text input is content-box. Given the\n same width, padding and border, the input therefore renders ~2rem wider \\u2014 the\n padding lands outside its box and inside the select\'s. Neither is wrong on its\n own; they just cannot be sized together until they agree. Every control the\n kit styles is border-box, so a declared width is the width that shows. */\n.lb-btn,.lb-select,.lb-input{box-sizing:border-box}\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/* Geometry, states and motion ported from the product\'s Button component\n (packages/ui/components/Button) so an artifact button and an app button are\n the same object. MUI spacing is 8px/unit, so its size=medium (py .875,\n px .875, borderRadius .875) is 7px/7px/7px and large (py 1.75, px 2,\n borderRadius 1.25) is 14px/16px/10px. A control sitting in a row beside a\n select reads as medium, so that is the default here; .lb-btn-lg opts into\n large. The app\'s default variant is secondary \\u2014 white ground, gray-5\n border, black text \\u2014 which is what an unadorned .lb-btn already was. */\n.lb-btn{font:inherit;font-family:var(--lb-font);font-size:0.8125rem;font-weight:600;\ncolor:var(--color-black);background-color:var(--color-white);\nborder:1px solid var(--color-gray-5);\nborder-radius:0.4375rem;corner-shape:squircle;padding:0.4375rem 0.4375rem;min-height:2.125rem;\ncursor:pointer;user-select:none;-webkit-user-select:none;text-wrap:nowrap;\nfont-variant-numeric:tabular-nums;\ntransition:background-color .16s ease,border .16s ease,color .16s ease,\nopacity .16s ease,transform .16s cubic-bezier(0.23,1,0.32,1)}\n/* The app hovers the GROUND to gray-2, not the border. */\n@media (hover:hover){\n.lb-btn:hover:not([disabled]):not([data-lb-state=loading]){\nbackground-color:var(--color-gray-2)}}\n/* Front-loaded press, so the tap leads the colour crossfade. */\n.lb-btn:active:not([disabled]):not([data-lb-state=loading]){transform:scale(0.97)}\n.lb-btn[disabled]{opacity:.4;cursor:default}\n/* A text button with a label + icon: the app lays the content out as a flex row\n with a 6px gap (its content style uses gap 0.75). */\n.lb-btn{display:inline-flex;align-items:center;justify-content:center;gap:0.375rem}\n.lb-btn>svg{display:block;flex-shrink:0;width:1.05rem;height:1.05rem}\n/* Opt-in large, for a button that stands alone rather than in a control row. */\n.lb-btn-lg{border-radius:0.625rem;padding:0.875rem 1rem}\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/* This is the app\'s primary variant verbatim: black ground, white text, and a\n hover that mixes 32% gray-8 into the black rather than lightening opacity. */\n.lb-btn-submit{background-color:var(--lb-fg);color:var(--lb-field);\nborder-color:var(--lb-fg)}\n@media (hover:hover){\n.lb-btn-submit:hover:not([disabled]):not([data-lb-state=loading]){\nbackground-color:color-mix(in oklch,var(--lb-fg),var(--color-gray-8) 32%);\nborder-color:color-mix(in oklch,var(--lb-fg),var(--color-gray-8) 32%)}}\n/* The app\'s ai variant \\u2014 what QualifyButton/Requalify wears. Purple ground,\n purple-foreground text, border at 65% transparency of the foreground; hover\n mixes 7% black into the ground. Tokens mirror the product\'s style package. */\n.lb-btn-ai{background-color:var(--color-purple-background);\nborder-color:var(--color-purple-border);color:var(--color-purple-foreground)}\n@media (hover:hover){\n.lb-btn-ai:hover:not([disabled]):not([data-lb-state=loading]){\nbackground-color:color-mix(in oklch,var(--color-purple-background),black 7%)}}\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:0.5rem;flex-wrap:wrap}\n/* \\u2500\\u2500 Tag rows \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n Two kinds, deliberately different so they never read as the same axis.\n FIRMOGRAPHIC (.lb-tags-plain): what the company IS \\u2014 location, size, sector.\n Quiet grey, because it is context the rep skims.\n INTENT (.lb-tags-intent): what the qualifier FOUND \\u2014 a buying signal. Teal,\n matching the product\'s own taste-profile tags, because it is the reason the\n lead is on screen. Both wrap and both sit on the section\'s 8px. */\n.lb-tags-plain,.lb-tags-intent{display:flex;flex-wrap:wrap;gap:0.5rem}\n/* Both tags round at 0.375rem: they are the innermost surface, and a tag whose\n radius drifts from its neighbours reads as a different kind of object. Their\n own padding is small enough that concentric math against the card does not\n apply \\u2014 past a point the layers are separate surfaces, each chosen on its\n own. */\n.lb-tags-plain>*{display:inline-flex;align-items:center;\nbackground-color:var(--lb-surface);border:1px solid var(--lb-border);\ncolor:var(--lb-muted);border-radius:0.375rem;padding:0.125rem 0.375rem;\nfont-size:0.75rem;font-weight:500;line-height:1.5;white-space:nowrap}\n.lb-tags-intent>*{display:inline-flex;align-items:center;\nbackground-color:var(--color-teal-background);border:1px solid var(--color-teal-border);\ncolor:var(--color-teal-foreground);border-radius:0.375rem;padding:0.1875rem 0.4375rem;\nfont-size:0.75rem;line-height:1.4}\n/* The honest empty state: a blank tag row reads as a rendering bug, so say\n what is missing and what fixes it. */\n.lb-tags-empty{font-size:0.78125rem;line-height:1.45;color:var(--lb-muted);\nfont-style:italic}\n/* The chips row is empty until a taste or a status lands. Left in flow it is a\n zero-height element that still consumes the gap above the first section, so\n hide it while it holds nothing \\u2014 set the hidden attribute from the code that\n paints the chips, since it is that code which knows. */\n.lb-chips[hidden]{display:none}\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/* Counts line up only when the digits do; a leaderboard whose numbers jitter\n column-to-column cannot be scanned down. */\n.lb-table td[data-num],.lb-table th[data-num]{text-align:end;\nfont-variant-numeric:tabular-nums}\n/* A sortable header is a control: it must look clickable and say which way it\n is sorting, in text as well as by arrow \\u2014 aria-sort is the accessible half. */\n.lb-table th[aria-sort]{cursor:pointer;user-select:none;color:var(--lb-fg)}\n.lb-table th[aria-sort]:hover{text-decoration:underline}\n.lb-table th[aria-sort=ascending]::after{content:" \\\\2191"}\n.lb-table th[aria-sort=descending]::after{content:" \\\\2193"}\n.lb-table th[aria-sort=none]::after{content:" \\\\2195";opacity:.35}\n/* The row a manager is acting on. */\n.lb-table tbody tr[aria-selected=true]{background-color:var(--lb-chip-bg)}\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`,R="lb-styles";var U="0.5.0",_=class extends Error{constructor(r,o={}){super(r);i(this,"code");i(this,"raw");this.name="LbError",this.code=o.code,this.raw=o.raw}},z=null,L=3e4;function $(){let e=globalThis.cowork;return e&&typeof e.callMcpTool=="function"?(t,r)=>e.callMcpTool(t,r):null}function D(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 q(e){if(!e||typeof e!="object")return e;let t=e;if(t.isError)throw new _(D(e)??"tool call failed",{raw:e});if("structuredContent"in t&&t.structuredContent!=null)return t.structuredContent;let r=D(e);if(r!=null)try{return JSON.parse(r)}catch{return r}return e}function B(e){if(e instanceof Error)return e.message;if(e&&typeof e=="object"){let t=e.message;if(typeof t=="string"&&t)return t;try{return JSON.stringify(e)}catch{}}return String(e)}function G(e){if(e instanceof _)return e.code;if(e&&typeof e=="object"){let t=e.code;if(typeof t=="string"&&t)return t}}function N(e){let t=G(e);return{message:B(e),unavailable:t==="unavailable",code:t}}function V(e={}){z=e.call??null,L=e.timeoutMs??3e4}function W(){if(typeof document>"u"||!document.head)return null;let e=document.getElementById(R);if(e)return e;let t=document.createElement("style");return t.id=R,t.textContent=M,document.head.appendChild(t),t}async function F(e,t){if(!L||L<=0)return e;let r,o=new Promise((n,a)=>{r=setTimeout(()=>a(new _(`"${t}" timed out after ${L}ms`,{code:"timeout"})),L)});try{return await Promise.race([e,o])}finally{r&&clearTimeout(r)}}async function g(e,t={}){if(z)return q(await F(Promise.resolve(z(e,t)),e));let r=$();if(!r)throw new _("Leadbay bridge unavailable (window.cowork absent)",{code:"unavailable"});return q(await F(Promise.resolve(r(e,t)),e))}var A=class{constructor(){i(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 K(e){return Array.isArray(e)?e.map(t=>t&&typeof t=="object"?t:{value:t,label:String(t)}):[]}var C=class extends A{constructor(r={}){super();i(this,"kind");i(this,"value");i(this,"options",[]);i(this,"loading",!1);i(this,"error",null);i(this,"ready",!1);i(this,"cfg");i(this,"depUnsubs",[]);i(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):K(o),this.ready=!0;let n=this.value==null?"":String(this.value);this.options.length&&(n===""||!this.options.some(a=>String(a.value)===n))&&(this.value=this.options[0].value)}catch(o){if(r!==this.seq)return;this.options=[],this.error=N(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 Z(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 k=class extends A{constructor(r){super();i(this,"loading",!1);i(this,"error",null);i(this,"lastResult",null);i(this,"cfg");this.cfg=r}async run(){if(this.loading)return;for(let n of this.cfg.fields??[]){let a=n.validate();if(a!=null){this.error={message:a,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 g(this.cfg.tool,n)}catch(n){this.error=N(n),this.loading=!1,this.emit(),this.cfg.onError?.(this.error);return}let o=Z(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()}},T=class extends A{constructor(r){super();i(this,"data",null);i(this,"loading",!1);i(this,"refreshing",!1);i(this,"error",null);i(this,"done",!1);i(this,"cfg");i(this,"timer",null);i(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=N(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)}},I=class extends A{constructor(r){super();i(this,"items",[]);i(this,"page",0);i(this,"pageSize");i(this,"total",0);i(this,"loading",!1);i(this,"error",null);i(this,"cfg");i(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=N(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 Y(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 Q(e,t){let r=()=>t.setValue(e.value);e.addEventListener("change",r);let o=t.subscribe(()=>{Y(e,t),e.disabled=t.loading,e.innerHTML="";for(let n of t.options){let a=document.createElement("option");a.value=String(n.value),a.textContent=n.label,e.appendChild(a)}e.value=t.value==null?"":String(t.value)});return()=>{e.removeEventListener("change",r),o()}}function J(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 a=t.subscribe(()=>{if(r)e.checked=!!t.value;else{let d=t.value==null?"":String(t.value);e.value!==d&&(e.value=d)}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),a()}}function X(e,t){let r=a=>{a.preventDefault(),t.run()};e.addEventListener("click",r);let o,n=t.subscribe(()=>{let a=t.error?.unavailable?"unavailable":t.loading?"loading":t.error?"error":t.lastResult!=null?"success":"ready";e.setAttribute("data-lb-state",a),"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),a==="success"&&(o=setTimeout(()=>{!t.loading&&!t.error&&e.setAttribute("data-lb-state","ready")},1600))});return()=>{e.removeEventListener("click",r),n(),o&&clearTimeout(o)}}var ee=["STILL_CHASING","COULD_NOT_REACH_STILL_TRYING","INTEREST_VALIDATED_OR_MEETING_PLANED","NOT_INTERESTED_LOST"],P=[{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 te(e){let t=String(e??"").trim().toUpperCase(),r=P.some(o=>o.value===t);return new C({kind:"select",value:r?t:"",load:async()=>P.slice()})}var O=[{value:"WANTED",label:"Wanted"},{value:"WON",label:"Won"},{value:"LOST",label:"Lost"},{value:"UNWANTED",label:"Unwanted"}],re={value:"",label:"Pick a status"};function oe(e){let t=String(e??"").trim().toUpperCase(),r=O.some(o=>o.value===t);return new C({kind:"select",value:r?t:"",validate:o=>String(o??"")===""?"Pick a status":null,load:async()=>r?O.slice():[re,...O]})}function ne(e){let t=()=>{let r=typeof e.leadIds=="function"?e.leadIds():e.leadIds;return Array.isArray(r)?r:e.leadId?[e.leadId]:[]};return new k({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,a=o[0]?.message??"write rejected";return o.length===n?`Status not applied: ${a}`:`${o.length} of ${n} leads failed: ${a}`}})}function ae(e){return new C({kind:"select",load:()=>g("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 ie(e){return new k({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 le(e){return new k({tool:"leadbay_add_note",fields:[e.note],args:()=>({leadId:e.leadId,note:e.note.value})})}function se(e){return new k({tool:"leadbay_like_lead",args:{lead_id:e}})}function ce(e){return new k({tool:"leadbay_dislike_lead",args:{lead_id:e}})}function de(e,t){return new T({autoLoad:!1,load:()=>g("leadbay_account_history",{leadId:e,_triggered_by:t})})}function ue(e,t){return new T({autoLoad:!1,load:()=>g("leadbay_research_lead_by_id",{leadId:e,_triggered_by:t})})}function he(e){let t=null;return new T({...e.autoLoad!==void 0?{autoLoad:e.autoLoad}:{},pollEvery:e.pollEvery??4e3,until:r=>!!r?.all_done,load:async()=>{if(!t){let r=await g("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 g("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 be(e){let t=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new I({pageSize:e.pageSize??20,load:async({page:r,pageSize:o})=>{let n=await g("leadbay_pull_leads",{page:r,count:o,...e.lensId?{lensId:e.lensId}:{},...t()?{order:t()}:{},_triggered_by:e.ask}),a=n.leads??[];return{items:a,total:n.pagination?.total??a.length}}})}function ge(e){let t=e.source??"followups",r=()=>typeof e.order=="string"?e.order:String(e.order?.value??"");return new I({pageSize:e.pageSize??20,load:async({page:o,pageSize:n})=>{let d=t==="campaign"?await g("leadbay_campaign_call_sheet",{campaign_id:e.campaignId,page:o,count:n,_triggered_by:e.ask}):await g("leadbay_pull_followups",{page:o,count:n,...e.city?{city:e.city}:{},...r()?{order:r()}:{},_triggered_by:e.ask}),p=d.leads??d.items??[];return{items:p,total:d.total_leads??d.pagination?.total??p.length}}})}async function fe(e){let t=await g("leadbay_pull_followups",{count:e.sample??200,filtered:!1,...e.personal===void 0?{}:{personal:e.personal},_triggered_by:e.ask}),r=new Map;for(let n of t.leads??[]){let a=n?.sector_id;if(a==null||a==="")continue;let d=String(a);r.set(d,(r.get(d)??0)+1)}let o=e.sectors??{};return[...r.entries()].map(([n,a])=>{let d=o[n];return{id:n,label:d??`Sector ${n}`,sampled:a,resolved:d!=null}}).sort((n,a)=>a.sampled-n.sampled||n.id.localeCompare(a.id))}async function pe(e){let t=[];e.sectorIds?.length&&t.push({type:"sector_ids",sectors:e.sectorIds,is_excluded:!1});let r=await g("leadbay_pull_followups",{count:1,set_filter:{criteria:t},...e.city?{city:e.city}:{},...e.cityId?{city_id:e.cityId}:{},...e.personal===void 0?{}:{personal:e.personal},_triggered_by:e.ask}),o=r.active_filters?.criteria??[],n=new Set;e.sectorIds?.length&&n.add("sector_ids"),(e.city||e.cityId)&&n.add("location_ids");let a=new Set(o.map(l=>typeof l?.type=="string"?l.type:null).filter(l=>l!=null)),d=(e.sectorIds??[]).slice().sort().join(","),p=o.filter(l=>l?.type==="sector_ids").flatMap(l=>Array.isArray(l.sectors)?l.sectors:[]).slice().sort().join(","),m=[...n].every(l=>a.has(l)),w=[...a].every(l=>n.has(l));return{total:v(r.pagination?.total),applied:o,trusted:m&&w&&d===p}}function me(e){return new T({load:()=>g("leadbay_team_activity",{weeks:e.weeks??4,_triggered_by:e.ask})})}var we="http://www.w3.org/2000/svg",x=(e,t)=>{let r=document.createElementNS(we,e);for(let[o,n]of Object.entries(t))r.setAttribute(o,String(n));return r};function v(e){return typeof e=="number"&&isFinite(e)?e:0}function ye(e,t={}){let r=Array.isArray(e)?e:[];if(r.length===0){let c=document.createElement("div");c.className="lb-empty";let f=document.createElement("div");if(f.className="lb-empty-title",f.textContent=t.emptyTitle??"Nothing in this window",c.appendChild(f),t.emptyHint){let E=document.createElement("div");E.className="lb-empty-hint",E.textContent=t.emptyHint,c.appendChild(E)}return c}let o=t.width??640,n=t.height??160,a=40,d=8,p=12,m=26,w=o-a-d,l=n-p-m,S=Math.max(1,...r.map(c=>v(c.count))),h=c=>r.length===1?a+w/2:a+c/(r.length-1)*w,s=c=>p+l-v(c)/S*l,u=x("svg",{class:"lb-chart",viewBox:`0 0 ${o} ${n}`,preserveAspectRatio:"none",role:"img","aria-label":t.label??`Trend across ${r.length} points, peak ${S}`});for(let c of[0,S]){u.appendChild(x("line",{class:"lb-chart-grid",x1:a,x2:o-d,y1:s(c),y2:s(c)}));let f=x("text",{x:a-6,y:s(c)+4,"text-anchor":"end"});f.textContent=String(c),u.appendChild(f)}let b=r.map((c,f)=>`${h(f)},${s(c.count)}`);u.appendChild(x("path",{class:"lb-chart-area",d:`M${h(0)},${s(0)} L${b.join(" L")} L${h(r.length-1)},${s(0)} Z`})),u.appendChild(x("path",{class:"lb-chart-line",d:`M${b.join(" L")}`})),r.forEach((c,f)=>{u.appendChild(x("circle",{class:"lb-chart-dot",cx:h(f),cy:s(c.count),r:3}))});let y=r.length===1?[[0,"middle"]]:[[0,"start"],[r.length-1,"end"]];for(let[c,f]of y){let E=x("text",{x:h(c),y:n-8,"text-anchor":f});E.textContent=String(r[c].date??"").slice(0,10),u.appendChild(E)}return u}function ve(e){let t=document.createElement("div");t.className="lb-tiles";for(let r of e){let o=document.createElement("div");o.className="lb-tile";let n=document.createElement("span");n.className="lb-tile-label",n.textContent=r.label;let a=document.createElement("span");a.className="lb-tile-value",a.textContent=String(r.value),o.append(n,a),t.appendChild(o)}return t}function ke(e){let{rows:t,columns:r}=e;if(!t||t.length===0){let m=document.createElement("div");m.className="lb-empty";let w=document.createElement("div");if(w.className="lb-empty-title",w.textContent=e.emptyTitle??"Nothing to show",m.appendChild(w),e.emptyHint){let l=document.createElement("div");l.className="lb-empty-hint",l.textContent=e.emptyHint,m.appendChild(l)}return m}let o=e.sortKey??r[0].key,n=e.sortDir??"descending",a=document.createElement("table");a.className="lb-table";let d=()=>{a.textContent="";let m=document.createElement("thead"),w=document.createElement("tr");for(let h of r){let s=document.createElement("th");s.textContent=h.label,h.num&&s.setAttribute("data-num",""),s.setAttribute("aria-sort",h.key===o?n:"none"),s.setAttribute("tabindex","0"),s.setAttribute("role","button");let u=()=>{o===h.key?n=n==="ascending"?"descending":"ascending":(o=h.key,n=h.num?"descending":"ascending"),d(),e.onSort?.(o,n)};s.addEventListener("click",u),s.addEventListener("keydown",b=>{(b.key==="Enter"||b.key===" ")&&(b.preventDefault(),u())}),w.appendChild(s)}m.appendChild(w),a.appendChild(m);let l=t.slice().sort((h,s)=>{let u=h[o],b=s[o];if(typeof u=="string"||typeof b=="string"){let y=String(u??"").localeCompare(String(b??""));return n==="ascending"?y:-y}return n==="ascending"?v(u)-v(b):v(b)-v(u)}),S=document.createElement("tbody");for(let h of l){let s=document.createElement("tr");s.setAttribute("aria-selected","false");for(let u of r){let b=document.createElement("td");u.num&&b.setAttribute("data-num","");let y=u.cell?.(h);y==null?b.textContent=u.num?String(v(h[u.key])):String(h[u.key]??"\\u2014"):typeof y=="string"?b.textContent=y:b.appendChild(y),s.appendChild(b)}S.appendChild(s)}a.appendChild(S)};d();let p=document.createElement("div");return p.style.overflowX="auto",p.appendChild(a),p}var xe={VERSION:U,configure:V,styles:W,call:g,field:e=>new C(e),action:e=>new k(e),resource:e=>new T(e),list:e=>new I(e),bindSelect:Q,bindValue:J,bindAction:X,sparkline:ye,tiles:ve,leaderboard:ke,campaigns:ae,segmentCount:pe,portfolioSectors:fe,outreach:ie,note:le,like:se,dislike:ce,leadStatus:oe,setStatus:ne,sortOrder:te,leadHistory:de,leadProfile:ue,enrichment:he,callList:ge,leadList:be,teamActivity:me,EPILOGUE_STATUSES:ee,LEAD_STATUSES:O,SORT_ORDERS:P};typeof globalThis<"u"&&(globalThis.LeadbayArtifacts=xe);})();';
|
|
25995
|
+
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.segmentCount({sectorIds, city, ask})` | `Promise<{total, applied, trusted}>` | how many Monitor leads match one sector/location \u2014 `count: 1` + `pagination.total`, so a segment costs one cheap call |\n| `lb.portfolioSectors({sample, sectors, ask})` | `Promise<PortfolioSector[]>` | which sectors the user ACTUALLY holds \u2014 samples one page of followups, tallies `sector_id`, resolves names against an embedded taxonomy |\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**Rendering helpers** \u2014 the only three things the library draws, and only\nbecause hand-rolling them goes wrong the same way every time: an SVG whose\npoints escape the viewBox, a series that draws empty axes when it is empty, a\nleaderboard whose digits do not line up. Each returns a DETACHED element you\nplace; none injects itself.\n\n| Call | Returns | For |\n|---|---|---|\n| `lb.sparkline(points, {label, emptyTitle, emptyHint})` | `<svg class="lb-chart">` or an `lb-empty` block | a trend from `[{date, count}]`, themed so it reads in light and dark |\n| `lb.tiles([{label, value}])` | `<div class="lb-tiles">` | headline figures \u2014 only the few that ARE the point |\n| `lb.leaderboard({rows, columns, sortKey, sortDir, cell})` | a scroll-wrapped `lb-table` | a sortable table; `aria-sort` + keyboard headers, `num` columns tabular and end-aligned |\n\n`sparkline` returns the empty block INSTEAD of a chart when the series is\nempty \u2014 an empty window is a real answer, and empty axes read as broken.\n`leaderboard` does the same for no rows. Sorting there is client-side by\ndesign: a roll-up arrives whole, unlike a lead list the backend pages.\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-sections` / `lb-section` / `lb-sec-title` | the card\'s section stack (16px between, 8px within) and a section\'s uppercase title |\n| `lb-card-top` / `lb-lead-title` / `lb-card-foot` | header row (leading control \xB7 title \xB7 trailing controls), the bold underlined company name, and the footer\'s leading-action/spacer/trailing-link row |\n| `lb-tags-plain` / `lb-tags-intent` / `lb-tags-empty` | firmographic tags (grey: what the company IS) and intent tags (teal: what the qualifier FOUND), plus the honest empty state for either |\n| `lb-toolbar` / `lb-field` / `lb-field-label` / `lb-field-inline` | the bar above a deck: three groups (tally \xB7 narrow \xB7 act), and a labelled control whose caption stacks above it \u2014 `lb-field-inline` keeps a checkbox beside its words |\n| `lb-tally` / `lb-status` / `lb-status-dot` | the row count (tabular, so it does not jitter as filters change) and a connection indicator whose live/dead states differ in SHAPE, not only colour |\n| `lb-pager` / `lb-pager-range` | the pager below a deck \u2014 prev/next plus an honest "21\u201340 of 60" range; pair it with `data-lb-state="loading"` on the deck so a page flip dims the old rows instead of blanking them |\n| `lb-tiles` / `lb-tile` / `lb-tile-label` / `lb-tile-value` | a row of figures for a dashboard\'s headline numbers \u2014 only for the few that ARE the point |\n| `lb-chart` (+ `lb-chart-line` / `-area` / `-dot` / `-grid`) | an inline-SVG trend that takes its colours from the theme, so it reads in both; no CDN |\n| `lb-empty` / `lb-empty-title` / `lb-empty-hint` | the honest empty state \u2014 what it means and the way out, in place of a blank chart or table |\n| `lb-table th[aria-sort]` / `td[data-num]` | a sortable header (arrow + `aria-sort`) and a tabular numeric cell, so counts line up down a column |\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-btn-submit` / `lb-btn-ai` / `lb-btn-lg` | the app\'s `primary` / `ai` variants, and its `large` size |\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, so the row does not read as a set of\n peers: wrap each axis in an `lb-group`. Status is NOT one of those axes \u2014 it\n saves on change and carries no button at all (see "Save on change is the\n default for status" below). Reserve `lb-btn-submit` for a write that really\n does need a second step, such as the bulk apply across checked rows.\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. The status select\n sidesteps the question entirely by carrying no button at all \u2014 it saves on\n change \u2014 but the rule stands for any write whose meaning comes from the\n control beside it.\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: the pull-leads triage board (THE default board)\n\nWhen the rep accepts an interactive board after ANY tool that returns a batch\nof leads \u2014 `leadbay_pull_leads`, `leadbay_find_new_leads`,\n`leadbay_pull_followups`, `leadbay_campaign_call_sheet` \u2014 build THIS. It is a\nfixed recipe, not a starting point: the same board every time means a rep who\nlearned it once knows it everywhere. Deviate only when the rep asks for\nsomething specific.\n\nBuild it from the data ALREADY IN HAND \u2014 never re-call the tool that produced\nthe batch just to populate the board.\n\nTwo things change with the source, and nothing else does:\n\n- **The deep link\'s view.** `pull_leads` omits `in_monitor`/`in_discover`, so\n its leads are the Discover batch by definition; `pull_followups` carries\n `in_monitor: true` on every row, so a call board links to `monitor`; a\n campaign sheet needs `?campaign=<id>&lead=<id>`. See the leadUrl helper above.\n- **Which write leads the card.** A discovery batch is triaged (taste + status);\n a follow-up list is worked (`lb.outreach`, gated on a note). Order the\n Outreach and Status sections to match, but ship both either way.\n\n**Per card** \u2014 a header, then a stack of titled SECTIONS, then the write\nfeedback. The skin ships the whole geometry (`lb-sections` = 16px between\nsections, `lb-section` = 8px within), so build the structure and add no\nspacing CSS of your own:\n\n```html\n<article class="lb-card">\n <div class="lb-card-top"> <!-- checkbox \xB7 title \xB7 taste -->\n <input type="checkbox" aria-label="Select Acme Corp for bulk apply">\n <div class="lb-lead-title"><a href="https://acme.com">Acme Corp</a></div>\n <span class="lb-group"><!-- like / dislike, lb-btn-icon --></span>\n </div>\n <div class="lb-chips" hidden><!-- data-taste + data-status, see contract --></div>\n\n <div class="lb-sections">\n <div class="lb-section"> <!-- untitled: what it is -->\n <div class="lb-tags-plain"><span>Honfleur, Normandie</span><span>50\u201399</span></div>\n <div class="lb-sub">Why it fits, one sentence.</div>\n </div>\n <div class="lb-section"><div class="lb-sec-title">Fit</div>\u2026</div>\n <div class="lb-section">\n <div class="lb-sec-title">Intent tags</div>\n <div class="lb-tags-intent"><span>Vente terrain</span>\u2026</div>\n <!-- no tags? <div class="lb-tags-empty">None \u2014 the qualifier found no\n buying signal</div>, never an empty row -->\n </div>\n <div class="lb-section"><div class="lb-sec-title">Data</div>\u2026</div>\n <div class="lb-section">\n <div class="lb-sec-title">Status</div>\n <select class="lb-select" aria-label="Lead status for Acme Corp"></select>\n </div>\n <div class="lb-section">\n <div class="lb-sec-title">Outreach</div>\n <div class="lb-stack"> <!-- one control per line -->\n <select class="lb-select" aria-label="Outreach result for Acme Corp"></select>\n <input class="lb-input" aria-label="Outreach note for Acme Corp">\n <button class="lb-btn lb-btn-submit">Log outreach</button>\n </div>\n </div>\n <details class="lb-section"><!-- lazy full profile, see below --></details>\n <div class="lb-card-foot">\n <button class="lb-btn lb-btn-ai">Requalify</button>\n <span class="lb-spacer"></span>\n <a class="lb-link-out">Open in Leadbay</a>\n </div>\n </div>\n\n <div class="lb-msg" role="status" aria-live="polite"></div>\n</article>\n```\n\nWire them with `lb.like` / `lb.dislike`, `lb.leadStatus()` +\n`lb.setStatus({leadId, status, ask})`, and `lb.outreach({leadId, ask, status,\nnote})` \u2014 the note field gated by a `validate` so an empty note cannot log.\n\nFour rules the structure encodes, each of which a hand-built card gets wrong:\n\n- **One section per titled block.** Two titles in one `lb-section` share its\n 8px gap instead of the 16px between sections, so the second title reads as\n part of the first block. Fit and Intent tags are the pair this catches.\n- **A section title sits at section level**, as a direct child. Nested inside a\n row (beside a verdict, say) it stops being a peer of the other titles and the\n card loses its scan order.\n- **No date input.** Status saves on change and the backend stamps the date as\n "now"; a picker is only for backdating, which this board does not do.\n- **Controls stack and span their section.** A select sizes to its longest\n option and a text input to a UA default, so side by side they come out\n different widths despite identical padding and height.\n\nOnly the bulk apply keeps a submit button, because it fans out across checked\nrows and takes a `confirm`.\n\n**Toolbar** \u2014 a tally, four filters (CRM status \xB7 taste \xB7 qualifier verdict \xB7\nsector), select-all + one `lb.setStatus({leadIds})` bulk write, and\n`lb.sortOrder()` bound to the list\'s `order`. Filters are CLIENT-side over the\nloaded page; sort is SERVER-side, so changing it must `loadPage(0)` (see the\nsorting rule above). The skin ships its geometry too:\n\n```html\n<div class="lb-toolbar">\n <span class="lb-tally">5 leads</span>\n\n <span class="lb-group"> <!-- narrow: sort + filters -->\n <label class="lb-field">\n <span class="lb-field-label">Sort</span>\n <select class="lb-select" aria-label="Sort order"></select>\n </label>\n <label class="lb-field">\n <span class="lb-field-label">Status</span>\n <select class="lb-select" aria-label="Filter by CRM status"></select>\n </label>\n <!-- taste, verdict \u2026 -->\n </span>\n\n <span class="lb-spacer"></span>\n\n <span class="lb-group"> <!-- act on the selection -->\n <label class="lb-field lb-field-inline">\n <input type="checkbox"><span class="lb-field-label">Select all</span>\n </label>\n <select class="lb-select" aria-label="Status for selected leads"></select>\n <button class="lb-btn lb-btn-submit">Apply to selected</button>\n </span>\n\n <span class="lb-status" data-live="yes"> <!-- shape, not just colour -->\n <span class="lb-status-dot"></span>live\n </span>\n\n <div class="lb-msg" role="status" aria-live="polite" style="flex:1 0 100%"></div>\n</div>\n```\n\nThree groups, because the bar does three things \u2014 report, narrow, act. Flat, the\nbulk commit reads as one more filter. Sort belongs in the narrow group but costs\na round trip the others do not, so mark that boundary (a divider, or its own\n`lb-group`) rather than letting four selects look interchangeable.\n\n**Pagination** \u2014 `lb.leadList` is already a paginated model: `.page`,\n`.pageSize`, `.total`, `.hasMore`, `.next()`, `.prev()`, `.loadPage(n)`. A\nboard that renders only page 0 silently hides the rest of the lens, so render\nthe pager whenever `total` exceeds one page:\n\n```html\n<div class="lb-pager">\n <button class="lb-btn" data-k="prev">Previous</button>\n <button class="lb-btn" data-k="next">Next</button>\n <span class="lb-spacer"></span>\n <span class="lb-pager-range">21\u201340 of 60</span>\n</div>\n```\n\n```js\nconst list = lb.leadList({ order: sort, ask: ASK, pageSize: 20 });\nlist.subscribe((l) => {\n deck.setAttribute("data-lb-state", l.loading ? "loading" : "ready");\n if (l.error) { renderError(l.error); return; } // .code says what to do\n if (!l.loading) renderCards(l.items); // keep old rows while loading\n const from = l.page * l.pageSize + 1;\n range.textContent = `${from}\u2013${Math.min(from + l.items.length - 1, l.total)} of ${l.total}`;\n prev.disabled = l.page === 0 || l.loading;\n next.disabled = !l.hasMore || l.loading;\n});\nprev.onclick = () => list.prev();\nnext.onclick = () => list.next();\n```\n\nFour things this gets right that a hand-rolled pager usually does not:\n\n- **A range, not a page number.** "21\u201340 of 60" says how much is left; "page 2"\n does not, and a rep cannot tell a short lens from a long one.\n- **Disable at the ends, and while loading.** `hasMore` is\n `(page + 1) * pageSize < total`, so it is false on the last page even when\n that page is full \u2014 a Next that fetches nothing reads as a broken button.\n- **Keep the old rows while the next page loads.** Blanking the deck loses the\n scroll position; `lb-deck[data-lb-state=loading]` dims and locks it instead.\n `loadPage` already drops a stale response if the rep flips pages quickly.\n- **Selection and page are independent.** A checked lead on page 1 stays in the\n bulk set after a flip, so either carry the selection across pages or clear it\n on the flip \u2014 silently dropping it means a bulk apply writes fewer leads than\n the rep ticked. Say which you chose in the UI.\n\nChanging the sort resets to page 0 (`loadPage(0)`), since the backend sorts the\nwhole lens and page 2 of the old order is not page 2 of the new one.\n\n**Rich profile data is LAZY.** The card renders from the list payload alone. A\n`lb.leadProfile(leadId, ask)` resource \u2014 sector label, `location.full`, real\n`linkedin_page`, the qualification Q&A \u2014 loads only when the rep EXPANDS that\ncard, one call for one lead they chose to open:\n\n```js\nconst profile = lb.leadProfile(lead.id, ASK); // autoLoad:false\nprofile.subscribe((p) => renderDetail(els.detail, p));\nels.expand.onclick = () => profile.load(); // one call, on demand\n```\n\nNever prefetch it for the batch: a 20-lead board would fire 20 requests to fill\nlines the rep may never read. The list payload already carries everything the\ncollapsed card shows.\n\n**Requalify** is `leadbay_bulk_qualify_leads` with `leadIds` (camelCase \u2014 NOT\n`lead_ids`) and `wait_for_completion: false`, so the button returns as soon as\nthe job is queued instead of holding through the poll. Give it\n`class="lb-btn lb-btn-ai"` \u2014 purple is the product\'s AI affordance, and the\napp\'s own QualifyButton is `variant="ai"`.\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>` that writes on change \u2014 no Apply\nbutton. You write the markup; `lb.leadStatus` fills the options and holds the\nvalue, `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" aria-label="Lead status for Acme Corp"></select>\n </div>\n <span id="msg" class="lb-msg" role="status" aria-live="polite"></span>\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\nconst sel = document.getElementById("st");\nlb.bindSelect(sel, status); // populates the 4 options\nsel.addEventListener("change", () => save.run()); // change \u2192 write, no button\n\nsave.subscribe((a) => { // render your own feedback\n sel.setAttribute("data-lb-state", // the select IS the surface\n a.loading ? "loading" : a.error ? "error" : a.lastResult ? "success" : "ready");\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: segment coverage (sector / location)\n\n"How many leads do we have in sector X" is one cheap call: the Monitor filter\nplus `count: 1`, read off `pagination.total`. `lb.segmentCount` wraps it.\n\n### Never hardcode the sector list\n\nA board that offers sectors must offer the ones the user HOLDS. Typing a few\nids into the markup gets this wrong in both directions \u2014 one real portfolio\'s\nhand-written list offered a sector holding 3 leads while omitting the\nthird-largest at 555.\n\nThere is no group-by on the Monitor, but every lead carries `sector_id`, so one\npage of followups names the sectors that matter:\n\n```js\n// SECTORS is { id: label }, embedded at BUILD time from leadbay_list_sectors.\n// The visible taxonomy is ~1,346 entries and does not change between runs, so\n// fetching it per page load buys nothing.\nconst held = await lb.portfolioSectors({ sectors: SECTORS, ask: ASK });\nfor (const s of held) {\n const opt = document.createElement(\'option\');\n opt.value = s.id;\n opt.textContent = s.label; // falls back to `Sector <id>` when unresolved\n sel.appendChild(opt);\n}\n```\n\n`sampled` is a SAMPLE count, for ordering the list. It is not the user\'s total\n\u2014 call `lb.segmentCount` for the exact figure once a sector is picked.\n\n**The taxonomy\'s `number_of_leads` is the WHOLE MARKET, not this user\'s book.**\nSupermarch\xE9s reads 11,460 nationally against 3,656 in one real portfolio, and\nSup\xE9rettes 5,710 against 143. Wiring a dropdown to that field ranks sectors the\nuser barely holds above ones they live in. Use it as a denominator if you are\nexplicitly showing market coverage, and label it as such \u2014 never present it as\nthe user\'s number.\n\n```js\nconst supermarkets = await lb.segmentCount({ sectorIds: ["5134"], ask: ASK });\n// \u2192 { total: 3656, applied: [...], trusted: true }\n```\n\n**Two traps, both observed against the live API \u2014 a hand-rolled version hits\nboth:**\n\n1. **The filter is server-side and STATEFUL.** `set_filter` overwrites ONE\n stored FilterItem per user, so consecutive calls are not independent: the\n next one inherits what the last one stored. Always send the complete\n criteria set, never a delta, and never assume a fresh call starts clean.\n2. **A rejected criterion fails SILENTLY.** Send a malformed criterion and the\n call returns **200 with the PREVIOUS filter still applied** \u2014 a plausible\n number answering a different question. `segmentCount` compares the echoed\n `active_filters` against what it sent and returns `trusted: false` on a\n mismatch. Never chart an untrusted count; say the segment could not be\n measured instead.\n\n The echo must match the request in BOTH directions. Every criterion asked\n for has to come back \u2014 a `city` / `cityId` as a `location_ids` criterion,\n since the composite resolves the free text through `/geo/search` into a type\n you never sent \u2014 and **nothing may come back that was not asked for**. The\n stored filter is cumulative, so narrowing a segment (sector+city \u2192 sector\n alone) leaves the dropped criterion in force: the count stays fenced to a\n city nobody asked about while every requested type is dutifully present. An\n unrequested criterion narrows a count exactly as a dropped one widens it.\n That rule also covers the unfiltered call on its own terms \u2014 with nothing\n wanted, "nothing extra" is "the echo is empty", which is what makes a\n whole-book denominator trustworthy.\n\n Sector VALUES are compared too, since a stale sector filter is still a sector\n filter. Locations are checked for presence only: the server picks the\n `admin_area_id` from free text, so the caller has nothing to compare against.\n\n**What you cannot build this way.** A score histogram needs every lead\'s\n`ai_agent_lead_score`, and there is no aggregation endpoint \u2014 bucketing 3,656\nleads means 732 pages. Sample the tails instead (`order: "SCORE:ASC"` and\n`"SCORE:DESC"`, a few hundred each) and **label the chart as a sample with its\nn**, or do not draw it. The same applies to a density map: coordinates are on\nevery lead, but aggregating thousands of points client-side is a batch job, not\na dashboard. Both want a backend stats endpoint.\n\n**Budget the calls.** A filtered count is normally 1\u20132 s, but adding a\n`last_action_date` criterion was observed at **54 s** on a 3.6k segment. Fire\nsegments in sequence with a visible progress cue, not a parallel burst, and\nnever block the first paint on them.\n\n## Recipe: manager dashboard\n\n`lb.teamActivity` returns `{range, reps, trend}`. A manager reads before they\nact, so the page is three bands in that order: the figures, the trend behind\nthem, then the per-rep table.\n\n```html\n<div class="lb-tiles"> <!-- only figures that ARE the point -->\n <div class="lb-tile"><span class="lb-tile-label">Activities</span>\n <span class="lb-tile-value">0</span></div>\n <!-- meetings / notes / contacts added \u2026 -->\n</div>\n\n<svg class="lb-chart" viewBox="0 0 640 160" role="img"\n aria-label="Weekly activity, 24 Jun to 16 Sep">\u2026</svg>\n\n<table class="lb-table">\n <thead><tr>\n <th aria-sort="none">Rep</th>\n <th data-num aria-sort="descending">Activities</th>\n <th data-num aria-sort="none">Meetings</th>\n </tr></thead>\n <tbody><tr aria-selected="false">\u2026</tr></tbody>\n</table>\n```\n\n```js\nconst team = lb.teamActivity({ weeks: 4, ask: ASK });\nteam.subscribe((t) => {\n if (t.error) { renderError(t.error); return; } // .code says what to do\n if (!t.data) return; // first load\n const { reps, trend, range } = t.data;\n const sum = (k) => reps.reduce((a, r) => a + (r[k] || 0), 0);\n\n stage.replaceChildren(\n lb.tiles([\n { label: "Activities", value: sum("total_activities") },\n { label: "Meetings", value: sum("meetings_or_interest") },\n { label: "Notes", value: sum("notes") },\n ]),\n lb.sparkline(trend, { // [] \u2192 the empty block\n label: `Activity from ${range.from} to ${range.to}`,\n emptyTitle: "No activity in this window",\n emptyHint: `Nothing logged between ${range.from} and ${range.to}. Widen the window.`,\n }),\n lb.leaderboard({\n rows: reps,\n sortKey: "total_activities",\n columns: [\n // reps[] carries email and nothing else \u2014 a mailto is the honest\n // affordance, since no MCP tool messages a rep.\n { key: "name", label: "Rep", cell: (r) => {\n const a = document.createElement("a");\n a.href = `mailto:${r.email}`; a.textContent = r.name || r.email;\n return a;\n } },\n { key: "total_activities", label: "Activities", num: true },\n { key: "meetings_or_interest", label: "Meetings", num: true },\n { key: "lost", label: "Lost", num: true },\n ],\n emptyTitle: "No reps in this window",\n emptyHint: "The backend scopes non-admins to themselves.",\n }),\n );\n});\nrefreshBtn.onclick = () => team.refresh();\n```\n\nFive things this gets right that a hand-built dashboard usually does not:\n\n- **An empty window is a real answer.** A quiet team returns `total_activities:\n 0` for every rep and `trend: []` \u2014 the common case on a new account. Draw\n `lb-empty` with what it means ("no activity logged in this window") and a way\n out (widen the range), never empty axes, which read as a broken chart.\n- **Chart from the tokens, not a CDN.** `lb-chart` styles an inline SVG from\n the theme, so it reads in light and dark. A hardcoded stroke disappears in\n one of the two, and a CDN chart that fails to load shows nothing at all \u2014 a\n sparse weekly series does not earn the dependency.\n- **Sorting is CLIENT-side here**, unlike a lead list: `reps` is the whole team\n in one response, so re-sorting reorders data you already hold. Reflect it in\n `aria-sort` on the header, not only with an arrow.\n- **Tabular numerals on every count** (`data-num`), or the columns will not\n line up and the leaderboard cannot be scanned down.\n- **Name the window.** `range.from`/`range.to` are resolved server-side and may\n not match what was asked for; printing them is what makes the figures\n auditable.\n\n**Writing to a rep is not in this payload.** `reps[]` carries `user_id`, `name`\nand `email` \u2014 enough to open a mail client with `mailto:`, and nothing more.\nThere is no MCP tool that messages a rep, so do not render a "message" button\nthat silently does nothing; a `mailto:` link is the honest affordance until\none exists.\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.';
|
|
25330
25996
|
}
|
|
25331
25997
|
});
|
|
25332
25998
|
|
|
@@ -25918,7 +26584,7 @@ var leadbay_build_campaign = `
|
|
|
25918
26584
|
|
|
25919
26585
|
You keep your own memory of how this user likes to work \u2014 tone, naming, formatting, what they ask you to skip. Leadbay does not store that and does not need to.
|
|
25920
26586
|
|
|
25921
|
-
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike rather than noting it.
|
|
26587
|
+
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike with their words as its \`reason\` rather than noting it.
|
|
25922
26588
|
|
|
25923
26589
|
|
|
25924
26590
|
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}}
|
|
@@ -26147,7 +26813,7 @@ var leadbay_daily_check_in = `
|
|
|
26147
26813
|
|
|
26148
26814
|
You keep your own memory of how this user likes to work \u2014 tone, naming, formatting, what they ask you to skip. Leadbay does not store that and does not need to.
|
|
26149
26815
|
|
|
26150
|
-
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike rather than noting it.
|
|
26816
|
+
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike with their words as its \`reason\` rather than noting it.
|
|
26151
26817
|
|
|
26152
26818
|
|
|
26153
26819
|
Run the Leadbay daily check-in for me. Treat this prompt the same way for any equivalent ask focused on NEW leads from the Discover wishlist: "get me leadbay leads", "best NEW leads to prospect today", "what's new today", "show me my batch", "let's prospect", "run my morning check-in", "my daily routine", "I do this every day", "every morning". For follow-up phrasings ("what should I follow up on", "leads I've already worked", "before my trip"), this is the wrong prompt \u2014 route to \`leadbay_followup_check_in\` instead. **Recurrence language ("I do this every day", "every morning", "my routine") always means this prompt \u2014 it is a daily batch check-in, not a follow-up.** If the user's intent is ambiguous ("what should I work on?"), ASK once before picking an entry point.
|
|
@@ -26350,7 +27016,7 @@ Pick 2\u20133 items below based on what was actually observed in the response. T
|
|
|
26350
27016
|
|
|
26351
27017
|
| Observation | Suggest | Calls |
|
|
26352
27018
|
|------------------------------------------------------------|--------------------------------------------------------------|--------------------------------------------------------|
|
|
26353
|
-
| \u2265
|
|
27019
|
+
| \u2265 1 lead returned \u2014 offer FIRST | "Build an interactive lead triage board" | leadbay_artifact_kit \u2192 its CANONICAL triage-board recipe, data in hand (do NOT re-call pull_leads) |
|
|
26354
27020
|
| \u2265 1 lead returned (any batch) | "Enrich top leads" (reveal decision-maker email/phone on the top leads) | leadbay_enrich_titles({ leadIds: shown leads[].id, lensId }) \u2014 scope to the leads JUST shown; OMIT \`titles\` so it runs the no-spend discovery preview. Confirm titles + channels, then re-call with titles + confirm to launch |
|
|
26355
27021
|
| \`has_more == true\` | "Pull the next page (page N+1 of M)" | leadbay_pull_leads(page = current + 1, lensId = pinned)|
|
|
26356
27022
|
| \u2265 3 rows have \`qualification_summary.answered == 0\` | "Deepen AI qualification on the rows without \u2756 caps" | leadbay_bulk_qualify_leads(leadIds=[\u2026]) |
|
|
@@ -26386,7 +27052,7 @@ var leadbay_getting_started2 = `
|
|
|
26386
27052
|
|
|
26387
27053
|
You keep your own memory of how this user likes to work \u2014 tone, naming, formatting, what they ask you to skip. Leadbay does not store that and does not need to.
|
|
26388
27054
|
|
|
26389
|
-
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike rather than noting it.
|
|
27055
|
+
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike with their words as its \`reason\` rather than noting it.
|
|
26390
27056
|
|
|
26391
27057
|
|
|
26392
27058
|
Walk me through Leadbay. Treat these the same way: "I'm new here", "how do I
|
|
@@ -27199,7 +27865,7 @@ var leadbay_new_leads = `
|
|
|
27199
27865
|
|
|
27200
27866
|
You keep your own memory of how this user likes to work \u2014 tone, naming, formatting, what they ask you to skip. Leadbay does not store that and does not need to.
|
|
27201
27867
|
|
|
27202
|
-
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike rather than noting it.
|
|
27868
|
+
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike with their words as its \`reason\` rather than noting it.
|
|
27203
27869
|
|
|
27204
27870
|
|
|
27205
27871
|
IRON LAW \u2014 NO FABRICATION. Every lead id, contact email, custom field id, mapping decision, and tool argument must trace to a value you read from the file the user attached or to an output from a leadbay_* tool call in this session. Do not invent values. Do not "fill in" a missing leadId with a name match. Do not synthesize a CRM id from a guess. If a value is missing, leave the field blank and say so.
|
|
@@ -27472,7 +28138,7 @@ var leadbay_prospecting_overview = `
|
|
|
27472
28138
|
|
|
27473
28139
|
You keep your own memory of how this user likes to work \u2014 tone, naming, formatting, what they ask you to skip. Leadbay does not store that and does not need to.
|
|
27474
28140
|
|
|
27475
|
-
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike rather than noting it.
|
|
28141
|
+
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike with their words as its \`reason\` rather than noting it.
|
|
27476
28142
|
|
|
27477
28143
|
|
|
27478
28144
|
# Leadbay Prospecting \u2014 Orientation
|
|
@@ -27893,7 +28559,7 @@ var leadbay_research_a_domain = `
|
|
|
27893
28559
|
|
|
27894
28560
|
You keep your own memory of how this user likes to work \u2014 tone, naming, formatting, what they ask you to skip. Leadbay does not store that and does not need to.
|
|
27895
28561
|
|
|
27896
|
-
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike rather than noting it.
|
|
28562
|
+
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike with their words as its \`reason\` rather than noting it.
|
|
27897
28563
|
|
|
27898
28564
|
|
|
27899
28565
|
IRON LAW \u2014 NO FABRICATION. Every lead id, contact email, custom field id, mapping decision, and tool argument must trace to a value you read from the file the user attached or to an output from a leadbay_* tool call in this session. Do not invent values. Do not "fill in" a missing leadId with a name match. Do not synthesize a CRM id from a guess. If a value is missing, leave the field blank and say so.
|
|
@@ -28116,12 +28782,51 @@ End with a NEXT STEPS chip via your host's choice widget (\`ask_user_input_v0\`
|
|
|
28116
28782
|
|
|
28117
28783
|
Done. The lens is live, the validated cohort is persisted as named campaigns, and the manager knows where the cross-user-visibility gap is.
|
|
28118
28784
|
`;
|
|
28785
|
+
var leadbay_sync_outreach = `
|
|
28786
|
+
Leadbay only knows the outreach someone logs in it. Keep that record current from my mailbox and calendar, so Leadbay can say who I contacted, when, and who went quiet.
|
|
28787
|
+
|
|
28788
|
+
IRON LAW \u2014 VERIFICATION REQUIRED. Before calling leadbay_report_outreach, you MUST collect one of: a gmail message id (verification.source = 'gmail_message_id'), a calendar event id (verification.source = 'calendar_event_id'), or a literal one-sentence user confirmation (verification.source = 'user_confirmed', verification.ref = the user's exact words). Skipping or fabricating verification poisons the human team's pipeline.
|
|
28789
|
+
|
|
28790
|
+
|
|
28791
|
+
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.
|
|
28792
|
+
|
|
28793
|
+
If the prompt's body and the tool's RENDERING appear to conflict, the tool's RENDERING wins for the structural layout; the prompt's voice wins for the commentary that surrounds it.
|
|
28794
|
+
|
|
28795
|
+
|
|
28796
|
+
The lead lookups are for matching. Do not render them. The only table this sync shows is the one the block ends with.
|
|
28797
|
+
|
|
28798
|
+
# PHASE 0 \u2014 WHAT YOU CAN READ
|
|
28799
|
+
|
|
28800
|
+
Check which mail, calendar and CRM tools you have in this session. With no mail tool and no calendar tool, tell me to connect Gmail or Google Calendar to this assistant, and stop.
|
|
28801
|
+
|
|
28802
|
+
# PHASE 1 \u2014 FIRST RUN
|
|
28803
|
+
|
|
28804
|
+
Run the block below once, over the last {{arg:lookback_days_or_default}} days instead of 2.
|
|
28805
|
+
|
|
28806
|
+
### Logging outreach from the user's mailbox and calendar
|
|
28807
|
+
|
|
28808
|
+
The \`leadbay_sync_outreach\` prompt runs the block below once, over the last 14 days instead of 2. It then schedules a daily task named "Leadbay outreach sync" whose instruction is the block, word for word. The block is the whole procedure, because a scheduled run sees nothing else.
|
|
28809
|
+
|
|
28810
|
+
> Leadbay outreach sync. Read my mailbox and calendar for the last 2 days: the emails I sent, the emails I received, and the meetings with outside guests. Never send, reply to, archive or label anything. Skip newsletters, no-reply senders, my own company's domain, and personal addresses such as gmail.com or orange.fr. For each other person, call leadbay_research_lead_by_name_fuzzy with only their full name as companyName, from the display name or from the address ("marcel.frei@" is Marcel Frei). This finds them on my own leads even when the company's website is another domain. If that finds no lead, or no contact on it with that exact email, call it again with the email's domain as companyName and website and the address as email. If there is still no lead, skip them and never import the company. Log each email and meeting with leadbay_report_outreach: the lead_id, the id of the contact with that email as contact_id, a one-line note ("Email sent: <subject>", "Replied: <what they said>" or "Meeting: <title>, <date>"), and verification {source: gmail_message_id, ref: the message id} or {source: calendar_event_id, ref: the event id}. Without a contact at that email, leave contact_id out. Set epilogue_status only on plain evidence: a meeting is INTEREST_VALIDATED_OR_MEETING_PLANED, a reply that declines is NOT_INTERESTED_LOST, a bounce is COULD_NOT_REACH_STILL_TRYING, and an email sent with no reply yet is STILL_CHASING. Log any other reply with no status and list it for me. A message or meeting already logged comes back in already_logged with nothing written. A CRM activity, or mail from a mailbox other than Gmail, has no id leadbay_report_outreach accepts as proof: list it, do not log it. End with a table of what you logged (lead, person, what, date, status) and what you skipped and why.
|
|
28811
|
+
|
|
28812
|
+
|
|
28813
|
+
# PHASE 2 \u2014 SCHEDULE THE DAILY TASK
|
|
28814
|
+
|
|
28815
|
+
Then make the same sync run every morning. Asking for this sync is the go-ahead, so do not ask whether to schedule it.
|
|
28816
|
+
|
|
28817
|
+
- If you have a tool that creates scheduled or recurring tasks, create a daily task named "Leadbay outreach sync" whose instruction is the block, word for word.
|
|
28818
|
+
- If you do not, show me the block and tell me to paste it into my assistant's scheduled tasks.
|
|
28819
|
+
|
|
28820
|
+
# PHASE 3 \u2014 REPORT
|
|
28821
|
+
|
|
28822
|
+
Show the table the block ends with. Then say in one line whether the daily task is scheduled or waiting for me to paste it.
|
|
28823
|
+
`;
|
|
28119
28824
|
var leadbay_top_accounts_to_activate = `
|
|
28120
28825
|
## WHAT LEADBAY SHOULD REMEMBER
|
|
28121
28826
|
|
|
28122
28827
|
You keep your own memory of how this user likes to work \u2014 tone, naming, formatting, what they ask you to skip. Leadbay does not store that and does not need to.
|
|
28123
28828
|
|
|
28124
|
-
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike rather than noting it.
|
|
28829
|
+
What Leadbay does need is anything that changes **who it should find**. When the user states targeting criteria in conversation ("I target fleets over 100 vehicles", "carriers are a bad fit unless they do last-mile delivery", "climate engineering is also my market"), call \`leadbay_refine_prompt\` so it changes what Leadbay surfaces for the whole org and on every future refresh \u2014 not just this conversation. When they say a specific lead is wrong for them, record the dislike with their words as its \`reason\` rather than noting it.
|
|
28125
28830
|
|
|
28126
28831
|
|
|
28127
28832
|
Build me a **top-{{arg:count_or_default}} account-conquest plan** \u2014 the accounts worth activating, ranked, each one carrying a strategic motif, a phone pitch and a three-step checklist. {{arg:territory_block}}
|
|
@@ -28285,13 +28990,13 @@ If the \`territory\` I named is a country, which one decides what you do:
|
|
|
28285
28990
|
plan is a Paris plan \u2014 and "covers all of France" printed above it is exactly the
|
|
28286
28991
|
confidently wrong deliverable this whole gate exists to stop, this time in my own
|
|
28287
28992
|
header rather than in a filter.
|
|
28288
|
-
**Read the
|
|
28289
|
-
\`location_ids
|
|
28993
|
+
**Read the lens's \`criteria\` in \`leadbay_my_lenses\`** \u2014 it names the lens's
|
|
28994
|
+
\`location_ids\`. \`leadbay_pull_leads\` returns only \`lens: {id}\`, not the
|
|
28290
28995
|
filter, and \`active_filters\` describes the separately-persisted MONITOR filter, not
|
|
28291
28996
|
the Discover lens; neither can settle this and neither is a substitute (same rule as
|
|
28292
28997
|
the Monitor-mirroring section below). Then say ONE of: the lens really is
|
|
28293
28998
|
workspace-wide, or it is scoped to \`<the places its filter names>\` \u2014 offering to clear
|
|
28294
|
-
that scope if national is what I meant. If you genuinely cannot read the
|
|
28999
|
+
that scope if national is what I meant. If you genuinely cannot read the criteria,
|
|
28295
29000
|
say the scope is unverified rather than calling it national. Then offer sector / size
|
|
28296
29001
|
/ sub-country region as the axes that would actually narrow it.
|
|
28297
29002
|
- **A different country, or a supra-national scope** \u2192 do NOT simply drop the scope and build the plan anyway. An unfiltered plan is this workspace's own accounts, which is not an answer to a request about somewhere else \u2014 delivering it under my heading would be a confidently wrong plan. Say the ask cannot be filled from this workspace and stop. **This is the one case that overrides DELIVER FIRST above**: shipping the plan anyway is the failure, not the fix.
|
|
@@ -28333,7 +29038,7 @@ If the org has none set, or they don't discriminate for this exercise, recommend
|
|
|
28333
29038
|
- **A persisted filter you didn't ask for** silently shrinks the known side, so a rep who once filtered Monitor to a city gets a "whole base" plan missing most of it.
|
|
28334
29039
|
- **Blindly passing \`filtered:false\`** does the opposite: Monitor goes org-wide while Discover stays on a scoped lens, so out-of-scope known accounts land in a plan headed with the lens's name.
|
|
28335
29040
|
|
|
28336
|
-
\u26A0 **You cannot mirror a geography you haven't read.** \`leadbay_pull_leads\` returns only \`lens: {id}\` \u2014 not the lens's filter \u2014 so capturing the id tells you nothing about which locations it covers. Before scoping Monitor to match a lens, read
|
|
29041
|
+
\u26A0 **You cannot mirror a geography you haven't read.** \`leadbay_pull_leads\` returns only \`lens: {id}\` \u2014 not the lens's filter \u2014 so capturing the id tells you nothing about which locations it covers. Before scoping Monitor to match a lens, read its \`criteria\` in **\`leadbay_my_lenses\`** \u2014 every lens there carries its filter's \`location_ids\` by name. If you cannot determine the lens's geography, do NOT guess: pull Monitor org-wide with \`filtered:false\` and say in the header that the known side is org-wide while Discover follows lens \`<id>\`, whose scope you could not read. An unstated mismatch is the failure; a stated one is honest.
|
|
28337
29042
|
|
|
28338
29043
|
So: **read the persisted filter first** (the response reports \`active_filters\`), then make it match the plan's declared scope. If the plan is scoped (a \`territory\`, or an active lens with its own geography), apply that same geography to Monitor. If the plan is genuinely org-wide, pass \`filtered:false\`. Either way, state the known side's scope in the header in the same breath as the Discover side \u2014 a plan whose two halves are scoped differently is misleading even when both halves are individually correct.
|
|
28339
29044
|
|
|
@@ -28775,6 +29480,12 @@ should I follow up on" to "I'll send via lemlist".
|
|
|
28775
29480
|
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"] },
|
|
28776
29481
|
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)"] },
|
|
28777
29482
|
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"] },
|
|
29483
|
+
leadbay_sync_outreach: { "name": "leadbay_sync_outreach", "short_description": `Keep Leadbay's record of who was contacted current: read my mailbox and
|
|
29484
|
+
calendar, log every email and meeting with a Leadbay lead on the person it
|
|
29485
|
+
involved, then schedule the same sync to run every day. Trigger on "log my
|
|
29486
|
+
emails in Leadbay", "sync my outreach", "keep Leadbay up to date with my
|
|
29487
|
+
emails".
|
|
29488
|
+
`, "arguments": [{ "name": "lookback_days", "description": "Optional: how many days back the first run reads (default 14). The daily task always reads the last 2 days.", "required": false }], "expected_calls": ["leadbay_research_lead_by_name_fuzzy", "leadbay_report_outreach"], "failure_modes": ["Sends, replies to, archives or labels an email. The sync only reads the mailbox.", "Logs outreach without a message id or event id read from the mailbox or calendar.", "Imports a company found in the mailbox that is not already a Leadbay lead.", "Logs on the lead when the person is a Leadbay contact, instead of passing that contact's id as contact_id.", "Sets a status from a reply whose meaning is not plain, instead of listing it for the user.", "Stops after the first run without scheduling the daily task, or without giving the user its text when the host cannot schedule.", "Logs a CRM activity or a non-Gmail message with an invented proof."] },
|
|
28778
29489
|
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."] },
|
|
28779
29490
|
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."] }
|
|
28780
29491
|
};
|
|
@@ -28794,6 +29505,7 @@ var PROMPT_CATALOG_BULLETS = {
|
|
|
28794
29505
|
leadbay_refine_audience: `- \`leadbay_refine_audience\` (required args: instruction): Refine the kind of leads Leadbay surfaces beyond firmographics, with a free-text instruction. Handles the clarification round-trip if the new prompt is ambiguous.`,
|
|
28795
29506
|
leadbay_research_a_domain: `- \`leadbay_research_a_domain\` (required args: domain): Resolve a company by name or domain across the user's visible Discover, Monitor, and Activate corpus, then return everything Leadbay knows about it.`,
|
|
28796
29507
|
leadbay_setup_team_prospecting: `- \`leadbay_setup_team_prospecting\` (required args: audience; optional args: rep_split): Manager-led prospecting setup: conversationally turn a natural-language audience ask into a Leadbay lens, validate the candidate leads, and persist them as one or more named campaigns the rep(s) can work through. Closes #3630 US3 end-to-end (within the current creator-scoped campaign visibility model).`,
|
|
29508
|
+
leadbay_sync_outreach: `- \`leadbay_sync_outreach\` (optional args: lookback_days): Keep Leadbay's record of who was contacted current: read my mailbox and calendar, log every email and meeting with a Leadbay lead on the person it involved, then schedule the same sync to run every day. Trigger on "log my emails in Leadbay", "sync my outreach", "keep Leadbay up to date with my emails".`,
|
|
28797
29509
|
leadbay_top_accounts_to_activate: `- \`leadbay_top_accounts_to_activate\` (optional args: count, territory): Build a ranked account-conquest plan from Leadbay data \u2014 the accounts worth activating, each with a motif, a pitch and a checklist, ranked by the strongest Leadbay signal. Every figure carries its source, and anything Leadbay can't measure is shown as OMITTED rather than estimated. Uses \`leadbay_bulk_qualify_leads\` and \`leadbay_enrich_titles\`. Trigger on "top 50 accounts to activate", "who should we go after".`,
|
|
28798
29510
|
leadbay_work_campaign: `- \`leadbay_work_campaign\` (optional args: campaign, mode): Work a campaign as a real outreach session: pick the campaign, assess what the user has (phones / emails / coords), then PROPOSE the right session mode (call sheet, email sheet, enrich titles first, map). After they pick, render \u2014 and as they dictate outcomes per lead, record both note + epilogue via \`leadbay_report_outreach\` in one round trip.`
|
|
28799
29511
|
};
|
|
@@ -28889,6 +29601,20 @@ var CATALOG = [
|
|
|
28889
29601
|
)
|
|
28890
29602
|
]
|
|
28891
29603
|
},
|
|
29604
|
+
{
|
|
29605
|
+
// The mailbox-to-Leadbay sync (product#4174). leadbay_pull_followups offers
|
|
29606
|
+
// it when most of the page has no outreach logged.
|
|
29607
|
+
name: "leadbay_sync_outreach",
|
|
29608
|
+
description: PROMPT_META.leadbay_sync_outreach.short_description,
|
|
29609
|
+
arguments: promptArguments("leadbay_sync_outreach"),
|
|
29610
|
+
render: (args) => [
|
|
29611
|
+
userMessage(
|
|
29612
|
+
substitutePlaceholders(leadbay_sync_outreach, {
|
|
29613
|
+
lookback_days_or_default: args.lookback_days || "14"
|
|
29614
|
+
})
|
|
29615
|
+
)
|
|
29616
|
+
]
|
|
29617
|
+
},
|
|
28892
29618
|
{
|
|
28893
29619
|
name: "leadbay_plan_tour_in_city",
|
|
28894
29620
|
description: PROMPT_META.leadbay_plan_tour_in_city.short_description,
|
|
@@ -29394,10 +30120,13 @@ function initTelemetry(opts) {
|
|
|
29394
30120
|
captureVersionUpdated(props) {
|
|
29395
30121
|
emit(EV_MCP_VERSION_UPDATED, { ...props });
|
|
29396
30122
|
},
|
|
29397
|
-
captureException(err, ctx) {
|
|
30123
|
+
captureException(err, ctx, identity) {
|
|
29398
30124
|
if (!sentryReady) return;
|
|
29399
30125
|
try {
|
|
29400
30126
|
Sentry.withScope((scope) => {
|
|
30127
|
+
if (identity?.email || identity?.name) {
|
|
30128
|
+
scope.setUser({ email: identity.email, username: identity.name });
|
|
30129
|
+
}
|
|
29401
30130
|
scope.setTag("tool", ctx.tool);
|
|
29402
30131
|
if (ctx.code) scope.setTag("error_code", ctx.code);
|
|
29403
30132
|
if (ctx.endpoint) scope.setTag("endpoint", ctx.endpoint);
|
|
@@ -29406,9 +30135,8 @@ function initTelemetry(opts) {
|
|
|
29406
30135
|
scope.setTag("http_status", String(ctx.http_status));
|
|
29407
30136
|
}
|
|
29408
30137
|
if (ctx.source) scope.setTag("source", ctx.source);
|
|
29409
|
-
|
|
29410
|
-
|
|
29411
|
-
}
|
|
30138
|
+
const organization = identity ? identity.groups?.organization : me?.organization?.id;
|
|
30139
|
+
if (organization) scope.setTag("organization", organization);
|
|
29412
30140
|
if (ctx.message) scope.setExtra("message", ctx.message);
|
|
29413
30141
|
if (ctx.hint) scope.setExtra("hint", ctx.hint);
|
|
29414
30142
|
if (ctx.triggered_by) scope.setExtra("triggered_by", ctx.triggered_by);
|
|
@@ -29818,6 +30546,7 @@ function buildAcknowledgeUpdateTool(opts) {
|
|
|
29818
30546
|
}
|
|
29819
30547
|
|
|
29820
30548
|
// src/server-instructions.generated.ts
|
|
30549
|
+
var COMPANY_FACTS = `Why a company was set aside is a fact Leadbay holds only if you write it down. A dislike saves it through its own \`reason\`. A CRM status stores no reason at all, so write it on the lead with leadbay_add_note, in the same turn, in two cases. You set a status for a reason about that one company, whether the user gave it or you found it yourself, in chat or in a scheduled run ("struck off the register, business bought by Thinkproject"). Or the user tells you something about one named company that only they know, without asking you to exclude it: "ce sont nos agents commerciaux sur le Sud-Ouest", "they're already a client". Use their words. One note per reason: a reason already saved by a dislike needs no second note. Without the note, the next session, the scheduled run and the user's colleagues see the company set aside and never learn why. The fact is not a fit rule: it never becomes a qualification question or a targeting-prompt clause, which score every company.`;
|
|
29821
30550
|
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.`;
|
|
29822
30551
|
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.`;
|
|
29823
30552
|
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.`;
|
|
@@ -29995,6 +30724,9 @@ function buildServerInstructions(exposed) {
|
|
|
29995
30724
|
if (has("leadbay_get_qualification_questions")) {
|
|
29996
30725
|
parts.push(STATED_RULES);
|
|
29997
30726
|
}
|
|
30727
|
+
if (has("leadbay_add_note")) {
|
|
30728
|
+
parts.push(COMPANY_FACTS);
|
|
30729
|
+
}
|
|
29998
30730
|
if (has("leadbay_create_topup_link")) {
|
|
29999
30731
|
parts.push(QUOTA_TOPUP);
|
|
30000
30732
|
}
|
|
@@ -31431,6 +32163,12 @@ var BrokenLeadbayClient = class extends LeadbayClient {
|
|
|
31431
32163
|
async requestRawBinary() {
|
|
31432
32164
|
throw this.stubError;
|
|
31433
32165
|
}
|
|
32166
|
+
// Every caller without a working token shares the placeholder above, so it
|
|
32167
|
+
// names nobody. Counting them together would answer the 61st of them with
|
|
32168
|
+
// TOO_MANY_LOOKUPS instead of telling them to sign in.
|
|
32169
|
+
callerKey() {
|
|
32170
|
+
return null;
|
|
32171
|
+
}
|
|
31434
32172
|
};
|
|
31435
32173
|
function makeBrokenClient(stubError, region) {
|
|
31436
32174
|
const baseUrl = region === "fr" ? "https://api-fr.leadbay.app" : "https://api-us.leadbay.app";
|
|
@@ -31646,7 +32384,7 @@ async function createDefaultUpdateStateStore(opts = {}) {
|
|
|
31646
32384
|
}
|
|
31647
32385
|
|
|
31648
32386
|
// src/oauth.ts
|
|
31649
|
-
import { createHash as
|
|
32387
|
+
import { createHash as createHash6, randomBytes } from "crypto";
|
|
31650
32388
|
import { createServer } from "http";
|
|
31651
32389
|
import { request as httpsRequestRaw } from "https";
|
|
31652
32390
|
import { spawn as spawn3 } from "child_process";
|
|
@@ -31711,7 +32449,7 @@ async function inferRegionViaStargate(opts) {
|
|
|
31711
32449
|
function generatePkce() {
|
|
31712
32450
|
const verifier = base64UrlEncode(randomBytes(32));
|
|
31713
32451
|
const challenge = base64UrlEncode(
|
|
31714
|
-
|
|
32452
|
+
createHash6("sha256").update(verifier, "ascii").digest()
|
|
31715
32453
|
);
|
|
31716
32454
|
return { verifier, challenge, method: "S256" };
|
|
31717
32455
|
}
|
|
@@ -32193,7 +32931,7 @@ var OAUTH_BASE_URLS = {
|
|
|
32193
32931
|
fr: "https://staging.api.leadbay.app"
|
|
32194
32932
|
}
|
|
32195
32933
|
};
|
|
32196
|
-
var VERSION = "0.39.
|
|
32934
|
+
var VERSION = "0.39.10";
|
|
32197
32935
|
var HELP = `
|
|
32198
32936
|
leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
|
|
32199
32937
|
|