@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 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; show quota only when asked (quota / account status) or a
1625
- window is exhausted. NEVER mention the lens unless asked (use
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\` (fall back to \`quota.org\` if \`user\`
1629
- absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table when
1630
- \`spend[]\` empty). Never say raw "credits".
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 \`lens://<lensId>/definition\` FIRST: it is the only place a lens's \`location_ids\` are visible (\`leadbay_pull_leads\` returns only \`lens: {id}\`, and \`leadbay_my_lenses\` returns no filter at all). Then either clear those criteria explicitly, or state which places the audience actually covers. If you cannot read the definition, say the scope is unverified rather than calling it workspace-wide.
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>". Offer a NEXT STEPS chip for "Add more
2381
- leads" or "Open in web UI". Don't dump the full campaign payload as a
2382
- table \u2014 the user only needs the confirmation + the id for follow-up.
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
- negative signal sent."), then continue the current flow (don't dump the full
2501
- lead card again).
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 signal is fed back into the scoring engine: disliked leads teach Leadbay what to filter out, improving the relevance of future batches.
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
- Dislike is a **permanent** negative signal \u2014 it influences scoring durably. 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.
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 options that match what actually happened \u2014 never all seven:
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 an org quota and burns a submit slot to stop in the same place | leadbay_account_status |
3005
- | Stopped on org quota and the user does not want to wait | "Top up to finish this run" | leadbay_create_topup_link |
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. Four reasons to write NOTHING
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\`). After a switch lead with
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:[{id, name, description, is_active}], active_lens_id}\`. The active lens is resolved from the user's last-requested lens, so \`is_active\` is authoritative even if a row's flag is stale.
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 TWO columns. Sort **active lens first**, then by \`name\`
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 before drafting" \u2192 \`leadbay_research_lead_by_id\`; "log sent outreach" \u2192 \`leadbay_report_outreach\`; "bulk enrich contacts" \u2192 \`leadbay_enrich_titles\`.
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\`, and an \`enrichment\` block describing async state.
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
- The first call on a lead records a \`LEAD_VIEWED\`-style prospecting action server-side (deduped, one per lead per session) so it ages out of the Discover "new" view.
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 any signals from a prior \`research_lead_by_id\` call into salesperson-voice talking points. Cite \`[source](url)\` inline when known.
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 [Contact name]**
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
- **H5: \u{1F3E2} History with [Company name]**
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, history items without descriptions, any field whose value is the string \`"null"\`, deprecated \`other_contacts_count\` (use \`additional_contacts_count\`).
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, using split_ai_summary) |
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?personal=&liked=&filtered=&count=&page=\` plus, when \`set_filter\` is supplied, a preceding \`POST /1.6/monitor/filter\`. The Monitor filter is a single \`FilterItem\` per user \u2014 refreshing restores it.
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\` is the backend's \`anyOf\` over 10 typed criteria: \`size\`, \`keywords\`, \`sector_ids\`, \`location_ids\`, \`custom_field\`(\`_comparison\`), \`yc\`, \`liked\`, \`last_action\` (MonitorActionType enum), \`last_action_date\` (with \`last_days\`).
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 backend rejects free-text in \`location_ids\`. Pass \`city: "<free-text>"\` and the composite calls \`/geo/search\` internally, picks the best match, merges its id into \`set_filter\`. Ambiguous matches return \`status: "ambiguous_locations"\` + \`location_ambiguities[]\` \u2014 pick an id and re-call with \`city_id\`.
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" \u2014 leads with active pushback are excluded from this view until expiry.
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 5 leads returned (any batch) | "Build an interactive lead triage board for this batch" | emit antArtifact from data in hand (do NOT re-call leadbay_pull_leads) |
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. Four reasons to write NOTHING
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({ leadId }) |
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({ leadId }) |
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. Four reasons to write NOTHING
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; show quota only when asked (quota / account status) or a
7424
- window is exhausted. NEVER mention the lens unless asked (use
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\` (fall back to \`quota.org\` if \`user\`
7428
- absent) as \`$used / $cap (N% used) \xB7 resets\` (or a resource-count table when
7429
- \`spend[]\` empty). Never say raw "credits".
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 options that match what actually happened \u2014 never all seven:
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 an org quota and burns a submit slot to stop in the same place | leadbay_account_status |
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 createHash("sha256").update(flat).digest("hex").slice(0, 32);
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 createHash2 } from "crypto";
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 createHash2("sha256").update(flat).digest("hex");
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 createHash3, randomUUID } from "crypto";
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 createHash3("sha256").update(stableStringify(payload)).digest("hex");
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
- var dislikeLead;
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
- return { applied: true, lead_id: params.lead_id, action: "disliked" };
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 messageOf(e) {
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: messageOf(e) });
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
- const profile = await getLeadProfile.execute(client, { leadId: params.leadId }, ctx);
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
- var pullFollowups;
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: monitor.pagination ?? null,
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
- var RESOLVE_TIMEOUT_MS, MAX_AMBIGUOUS_CANDIDATES, researchLeadByNameFuzzy;
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 `remove`", "Provide a full `questions` list, or `add`/`remove` entries.", "POST /organizations/{orgId}");
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
- message: "No enrichable contacts for the chosen titles. Try other titles from available_titles or recommendations.",
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 \`leadbay_my_lenses\` returns no filter, so neither can tell you what it 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.`
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 createHash4 } from "crypto";
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}-${createHash4("sha256").update(serialized).digest("hex").slice(0, 32)}`;
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
- lenses: lenses.map((l) => ({
24107
- id: sid(l.id),
24108
- name: l.name,
24109
- description: l.description ?? null,
24110
- is_active: sid(l.id) === active_lens_id,
24111
- is_default: l.is_default === true || l.default === true
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. Each: {id, name, description, is_active}.",
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 \`lens://${params.base ?? "<active lens id>"}/definition\`, which is the only place a lens's \`location_ids\` are visible (\`leadbay_pull_leads\` returns only \`lens: {id}\`, and \`leadbay_my_lenses\` returns no filter at all). 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.`
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 targetLeads = params.lead_ids ?? [params.lead_id];
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: targetLeads.map((id) => ({
25543
+ would_write_notes: (params.lead_ids ?? [params.lead_id]).map((id) => ({
24894
25544
  method: "POST",
24895
- path: `/leads/${id}/notes`,
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: targetLeads, status: epilogueWire }
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", `/leads/${leadId}/notes`, { note: noteBody });
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
- await client.requestVoid("POST", "/leads/epilogue", {
24921
- lead_ids: targetLeads,
24922
- status: epilogueWire
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 5 leads returned (any batch) | "Build an interactive lead triage board for this batch" | emit antArtifact from data in hand (do NOT re-call leadbay_pull_leads) |
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 \`lens://<id>/definition\` resource** \u2014 that is the only place a lens's
28289
- \`location_ids\` are visible. \`leadbay_pull_leads\` returns only \`lens: {id}\`, not the
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 definition,
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 the **\`lens://<id>/definition\` resource** \u2014 that is where the filter and its \`location_ids\` actually live. \`leadbay_my_lenses\` returns only id / name / description / active flags, so it cannot tell you a lens's geography and must not be used for this. 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.
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
- if (me?.organization?.id) {
29410
- scope.setTag("organization", me.organization.id);
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 createHash5, randomBytes } from "crypto";
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
- createHash5("sha256").update(verifier, "ascii").digest()
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.9";
32934
+ var VERSION = "0.39.10";
32197
32935
  var HELP = `
32198
32936
  leadbay-mcp ${VERSION} \u2014 Leadbay Model Context Protocol server
32199
32937