@leadbay/mcp 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -414,7 +414,21 @@ var LeadbayClient = class {
414
414
  return this.makeError("AUTH_EXPIRED", "Authentication token expired or invalid", "Your LEADBAY_TOKEN is no longer valid. Regenerate it: npx -y @leadbay/mcp login --email <you> --region <us|fr>, then restart your MCP client.", endpoint);
415
415
  }
416
416
  if (status === 429 || status === 402 || parsed?.error === "quota_exceeded" || parsed?.error?.code === "quota_exceeded") {
417
- return this.makeError("QUOTA_EXCEEDED", retryAfter ? `Quota exceeded \u2014 retry in ${retryAfter}s` : "Quota exceeded", retryAfter ? `Wait ${retryAfter}s before retrying. Check leadbay_get_quota to see which resource window was hit.` : "Wait, then retry. Check leadbay_get_quota to see which resource window (daily/weekly/monthly) was hit.", endpoint, retryAfter);
417
+ const hintBase = retryAfter ? `Wait ${retryAfter}s before retrying` : "Wait, then retry";
418
+ return this.makeError(
419
+ "QUOTA_EXCEEDED",
420
+ retryAfter ? `Quota exceeded \u2014 retry in ${retryAfter}s` : "Quota exceeded",
421
+ // The Leadbay user can either wait for the window to reset OR top up
422
+ // AI credits (which clears the throttle immediately). Tell the agent
423
+ // both options exist so it offers the top-up path to the user instead
424
+ // of forcing them to wait. Surface leadbay_create_topup_link so the
425
+ // agent can generate the URL itself instead of asking the user to
426
+ // navigate to a website. Once the user has topped up, the previous
427
+ // 429 is stale — retry the failed call.
428
+ `${hintBase}, OR top up AI credits \u2014 top-ups clear the throttle immediately. Offer the user to generate a Stripe checkout URL via leadbay_create_topup_link, OR direct them to app.leadbay.ai \u2192 Billing. Check leadbay_account_status / leadbay_get_quota to see which resource window (daily/weekly/monthly) was hit. Once the user has topped up, the previous QUOTA_EXCEEDED is stale \u2014 re-call leadbay_account_status to refresh, then RETRY the original operation.`,
429
+ endpoint,
430
+ retryAfter
431
+ );
418
432
  }
419
433
  if (status === 403) {
420
434
  const msg = parsed?.message || parsed?.error || parsed?.error?.message || "";
@@ -533,11 +547,44 @@ function parseRetryAfter(value) {
533
547
  }
534
548
 
535
549
  // ../core/dist/tool-descriptions.generated.js
536
- var leadbay_account_status = `Show the user's account state \u2014 admin rights, language, last-active lens, current quota usage across daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's intelligence is mid-regeneration. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption.
550
+ var leadbay_account_status = `## WHEN TO USE
537
551
 
538
- WHEN TO USE: at the start of a session to know what the agent can/can't do, or after a 429 to explain to the user which resource window was exhausted and when it resets.
552
+ Trigger phrases: "what's my account status", "how much quota do I have", "what lens am I on", "am I an admin", "is intelligence still regenerating", "I topped up / I bought credits / I added credits".
539
553
 
540
- WHEN NOT TO USE: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating.
554
+ Do NOT use for: "show me leads / today's prospects" \u2192 \`leadbay_pull_leads\`.
555
+
556
+ Prefer when: user opens with a meta question about themselves / their workspace \u2014 typically as a daily check-in opener
557
+
558
+ Examples that SHOULD invoke this tool:
559
+ - "What's my account status?"
560
+ - "How much quota do I have left this week?"
561
+ - "Is my intelligence still regenerating?"
562
+
563
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
564
+ - "Show me today's leads."
565
+ - "What should I follow up on?"
566
+ - "Run the daily check-in."
567
+
568
+ ## RENDER (quick)
569
+
570
+ Compact markdown: org name + admin badge on line 1; active lens on
571
+ line 2; per-window quota usage as \`(used / cap)\` chips for
572
+ llm_completion \xB7 ai_rescore \xB7 web_fetch. Surface regeneration flag
573
+ prominently if mid-regen.
574
+
575
+ ---
576
+
577
+ Show the user's account state \u2014 admin rights, language, last-active lens, current quota usage across daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's intelligence is mid-regeneration. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption.
578
+
579
+ **Top-ups always beat waiting.** When a quota window is hit, the user has two options: wait for the window reset (\`resets_at\` in each quota entry) OR top up AI credits. Top-ups clear the throttle IMMEDIATELY; they are not subject to the same window. When you tell the user about a 429 / quota exhaustion, ALWAYS surface both options \u2014 "wait until <reset>" or "top up now (I can generate the link)" \u2014 and let them pick. Never default-recommend "wait until tomorrow" when a 30-second top-up unblocks the same operation.
580
+
581
+ **Offer the top-up link via \`leadbay_create_topup_link\`.** When the user accepts the top-up offer, call \`leadbay_create_topup_link\` and surface the returned Stripe checkout URL as a clickable link. The user completes payment in their browser; nothing is charged just by generating the URL. For ongoing subscription changes (plan upgrade / payment method), use \`leadbay_open_billing_portal\` instead.
582
+
583
+ **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.**
584
+
585
+ WHEN TO USE: at the start of a session to know what the agent can/can't do, after a 429 to explain to the user which resource window was exhausted and when it resets (and to offer the top-up alternative), and after the user signals a top-up so the agent can resume the interrupted workflow.
586
+
587
+ WHEN NOT TO USE: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating. And: a recent quota snapshot showing "exhausted" is NOT a reason to refuse a write call when the user has just topped up \u2014 re-call this tool first, then proceed.
541
588
  `;
542
589
  var leadbay_add_note = `Add a note to a lead. Notes are visible to the whole organization in Leadbay.
543
590
 
@@ -567,7 +614,7 @@ var leadbay_bulk_enrich_status = `Check status + per-lead contacts for a bulk en
567
614
 
568
615
  WHEN TO USE: poll this after leadbay_enrich_titles returns a \`bulk_id\`. Default \`include_contacts=false\` for cheap status polls; set \`include_contacts=true\` once \`all_done\` flips for the final read.
569
616
 
570
- WHEN NOT TO USE: as a substitute for leadbay_research_lead \u2014 that already includes enriched contacts for a single lead.
617
+ WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id \u2014 that already includes enriched contacts for a single lead.
571
618
  `;
572
619
  var leadbay_bulk_qualify_leads = `Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch). Pass \`wait_for_completion:false\` to return quickly with \`{status:'running', qualify_id}\`; poll leadbay_qualify_status with that id. With \`wait_for_completion\` omitted/true, the legacy behavior polls until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null \`ai_agent_lead_score\`) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads.
573
620
 
@@ -611,6 +658,32 @@ Do not enumerate the affected leads \u2014 that's the job of \`leadbay_pull_lead
611
658
 
612
659
  ## NEXT STEPS \u2014 after kicking off bulk qualification
613
660
 
661
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
662
+
663
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
664
+
665
+ \`\`\`
666
+ ask_user_input_v0({
667
+ questions: [{
668
+ question: "What next?",
669
+ type: "single_select",
670
+ options: [
671
+ "<Suggest column from row 1>",
672
+ "<Suggest column from row 2>",
673
+ "<Suggest column from row 3>"
674
+ ]
675
+ }]
676
+ })
677
+ \`\`\`
678
+
679
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
680
+
681
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
682
+
683
+ ---
684
+
685
+
686
+
614
687
  Exactly two offers \u2014 keep it terse, this is a status tool:
615
688
 
616
689
  | Observation | Suggest | Calls |
@@ -658,6 +731,23 @@ WHEN NOT TO USE: from agent flow \u2014 leadbay_adjust_audience handles the draf
658
731
 
659
732
  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\`.
660
733
  `;
734
+ var leadbay_create_topup_link = `Generate a one-shot Stripe checkout session for an AI-credits top-up. Wraps \`POST /1.5/stripe/topup_checkout\` \u2192 \`{url}\`. Returns a fresh Stripe-hosted URL (~1 hour TTL); creating one does NOT charge the user \u2014 payment happens only after the user opens the URL and completes checkout in their browser.
735
+
736
+ **When a quota window is hit, offer top-up as the FIRST option.** A top-up clears the throttle immediately (no need to wait for the daily/weekly/monthly window reset). The flow:
737
+
738
+ 1. Operation returns QUOTA_EXCEEDED / 429.
739
+ 2. Tell the user which window was hit (from \`leadbay_account_status\` / \`leadbay_get_quota\`) and offer BOTH options: "wait until \`<resets_at>\`" OR "top up at app.leadbay.ai (I can generate the link)".
740
+ 3. If the user accepts top-up, call \`leadbay_create_topup_link\` and surface the returned \`url\` as a clickable link.
741
+ 4. Tell the user to complete checkout in their browser. After they return saying "I topped up" / "bought credits" / "added credits", call \`leadbay_account_status\` to confirm the refreshed state, then RETRY the originally failed call.
742
+
743
+ Do NOT call this tool defensively before any quota error \u2014 only after a real 429 or in response to the user's explicit "I want to top up" / "I'll buy credits". The URL is single-use and rotating it without the user asking just clutters the conversation.
744
+
745
+ \`leadbay_open_billing_portal\` is the companion tool for ongoing subscription management (upgrade / downgrade / payment methods) via the Stripe customer portal.
746
+
747
+ WHEN TO USE: when a quota error has blocked the user's intent and they want to keep working today rather than wait for the window reset; OR when the user explicitly asks for a top-up link.
748
+
749
+ WHEN NOT TO USE: pre-flight (the agent is not paying \u2014 the user is); for subscription / plan changes use \`leadbay_open_billing_portal\` instead; for read-only quota diagnostics use \`leadbay_account_status\` / \`leadbay_get_quota\`.
750
+ `;
661
751
  var leadbay_deselect_leads = `Remove leads from the user's transient selection.
662
752
 
663
753
  WHEN TO USE: when narrowing a previously-built selection without clearing it entirely.
@@ -672,6 +762,44 @@ WHEN TO USE: low-level \u2014 when you need raw paginated wishlist access withou
672
762
 
673
763
  WHEN NOT TO USE: as the agent's default lead-discovery entry point \u2014 use leadbay_pull_leads, which adds a one-line qualification summary per lead.
674
764
  `;
765
+ var leadbay_dislike_lead = `## WHEN TO USE
766
+
767
+ Trigger phrases: "I don't like this lead", "thumbs down", "not relevant", "wrong industry", "too small", "skip this one permanently", "not a fit", "no to this one".
768
+
769
+ Do NOT use for: "remind me about this lead later / snooze it / not now" \u2192 \`leadbay_set_pushback\`; "thumbs up / save this one / this is a good fit" \u2192 \`leadbay_like_lead\`.
770
+
771
+ Prefer when: user expresses durable rejection of a specific lead; pass the lead's UUID as \`lead_id\`. If the user just wants to defer for now, route to \`leadbay_set_pushback\` instead.
772
+
773
+ Examples that SHOULD invoke this tool:
774
+ - "Thumbs down \u2014 wrong industry."
775
+ - "Dislike this one, never show me leads like this again."
776
+ - "Not a fit at all, remove it."
777
+
778
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
779
+ - "Skip this for now, ask me again next month."
780
+ - "I'll look at this one later."
781
+ - "I like this lead \u2014 save it."
782
+
783
+ ## RENDER (quick)
784
+
785
+ One short confirmation line after the call returns ("\u{1F44E} Disliked **{company_name}** \u2014
786
+ negative signal sent."), then continue the current flow (don't dump the full
787
+ lead card again).
788
+
789
+ ---
790
+
791
+ 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.
792
+
793
+ Pass the lead's UUID as \`lead_id\`.
794
+
795
+ 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.
796
+
797
+ 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.
798
+
799
+ WHEN NOT TO USE: the user simply wants to defer a lead \u2014 use \`leadbay_set_pushback\` to snooze instead. Dislike is a permanent negative signal; pushback is a temporary deferral.
800
+
801
+ 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\`.
802
+ `;
675
803
  var leadbay_dismiss_clarification = `Dismiss the pending clarification without answering. Leadbay proceeds with its best guess. Admin-only.
676
804
 
677
805
  WHEN TO USE: when the user explicitly doesn't want to answer the disambiguation.
@@ -696,6 +824,172 @@ WHEN NOT TO USE: to enrich a single contact \u2014 that's leadbay_enrich_contact
696
824
 
697
825
  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\`.
698
826
  `;
827
+ var leadbay_followups_map = `## WHEN TO USE
828
+
829
+ Trigger phrases: "I'm going to <city>", "this week's trip", "leads I should visit in person", "visit in person", "show me followups in <city / state / country>", "leads in Texas / California / France / Bavaria etc.", "leads near <city>", "map of leads", "show me on a map", "itinerary", "plan my itinerary", "trip itinerary", "where are my leads".
830
+
831
+ Do NOT use for: "default follow-up table / status view" \u2192 \`leadbay_pull_followups\`; "new leads / today's prospects" \u2192 \`leadbay_pull_leads\`.
832
+
833
+ Prefer when: user signals geographic / map-style intent \u2014 even a passive 'before my trip' mention counts
834
+
835
+ Examples that SHOULD invoke this tool:
836
+ - "I'm flying to New York Thursday \u2014 who should I meet in person?"
837
+ - "Show my leads on a map for the SF trip."
838
+ - "Who can I visit while I'm in Chicago next week?"
839
+
840
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
841
+ - "What should I follow up on this week?"
842
+ - "Show me today's new prospects."
843
+ - "Tell me about Acme Corp."
844
+
845
+ ## RENDER (quick)
846
+
847
+ Two render surfaces \u2014 host picks. Primary: route to Claude's
848
+ \`places_map_display_v0\` widget with \`{name, address (lead.location.full),
849
+ latitude+longitude (lead.location.pos[0/1]), notes (short prose with
850
+ bare phone+email)}\` per lead. Fallback: per-lead markdown blocks
851
+ \`### **Company** \xB7 City, ST\` + one-sentence note + bare phone/email
852
+ \u2014 chat hosts auto-detect into place-card carousel. Detail below.
853
+
854
+ ---
855
+
856
+ Plot the user's follow-up leads on an interactive map \u2014 the canonical surface for travel / in-person / "I'm going to <city>" intent. Wraps \`leadbay_pull_followups\` (same params, response, store-then-apply geo filter); named separately so the agent has an explicit entry-point for travel intent and can route to \`places_map_display_v0\` (when the host exposes it) or emit place-card prose blocks (which most chat hosts auto-detect into Google Place cards).
857
+
858
+ **Common city aliases resolve automatically** \u2014 \`NYC\` / \`New York\` \u2192 City of New York, \`SF\` / \`S.F.\` \u2192 San Francisco, \`LA\` / \`L.A.\` \u2192 Los Angeles, \`DC\` / \`Washington D.C.\` \u2192 Washington, \`Philly\` \u2192 Philadelphia, \`Vegas\` \u2192 Las Vegas, \`NOLA\` \u2192 New Orleans. Pass either an abbreviation, a city name, or a pre-resolved \`city_id\`. Ambiguous matches surface as \`status: "ambiguous_locations"\` + \`location_ambiguities[]\` \u2014 pick an id and re-call with \`city_id\`.
859
+
860
+ **\`city\` is the universal geo arg \u2014 it resolves any admin level.** Despite the name, pass any place name there: states (\`"Texas"\`, \`"California"\`, \`"Bavaria"\`), countries (\`"France"\`, \`"United States"\`), regions (\`"New England"\`, \`"Bay Area"\`), neighborhoods (\`"Brooklyn"\`, \`"SoHo"\`), or cities. The \`/geo/search\` resolver indexes all levels \u2014 level 4 (state), level 2 (country), level 5 (city) \u2014 and the composite picks the best match. **Never** put a place name into \`keywords\` instead \u2014 that's a text-match against company descriptions, not a real geo filter (e.g. \`keywords: ["Texas"]\` returns \u22480 hits even when the user has dozens of Texas leads). If \`keywords: ["<PlaceName>"]\` returned empty, the correct next call is \`city: "<PlaceName>"\`, NOT the unfiltered Monitor view.
861
+
862
+ ---
863
+
864
+ ## RENDER \u2014 host-native map widget (REQUIRED, preferred)
865
+
866
+ When the host exposes Claude's \`places_map_display_v0\`, route the leads there. It owns the visual surface (markers, place-card carousel, "Notes from Claude") and beats inline prose by miles.
867
+
868
+ **Call shape \u2014 pass the most precise data you have. The richer the address + coordinates, the more likely Google resolves a real business listing and shows structured property pills (phone, website, Directions button, rating) on each card. Cheap city-level data => basic pin + your notes string only.**
869
+
870
+ \`\`\`
871
+ places_map_display_v0({
872
+ locations: leads.map(l => ({
873
+ name: l.company_name ?? l.name,
874
+ // \u2605 REQUIRED \u2014 pos is [lat, lng] in our payload. Pass them split.
875
+ latitude: l.location.pos[0],
876
+ longitude: l.location.pos[1],
877
+ // \u2605 Pass the FULL detailed address from l.location.full
878
+ // ("1140, 6th Avenue, 10036, City of New York, New York, United States"),
879
+ // NOT a city-level fallback. Google's place lookup needs the street
880
+ // + ZIP to resolve a business listing \u2192 that's what unlocks the
881
+ // structured phone/website/rating pills on the card.
882
+ address: l.location.full ?? [l.location.city, l.location.state, l.location.country].filter(Boolean).join(", "),
883
+ notes: <one-sentence pitch \u2014 see notes recipe below>,
884
+ // place_id omitted \u2014 Leadbay's backend doesn't store Google Place
885
+ // IDs. Google resolves implicitly from name + lat/lng + address.
886
+ // If it doesn't find a listing (small B2B, no Google business page),
887
+ // the card falls back to your notes string only \u2014 that's why the
888
+ // notes string MUST be self-sufficient (phone + email inline).
889
+ })),
890
+ // travel_mode: "driving" if the user mentioned driving / trip, etc.
891
+ })
892
+ \`\`\`
893
+
894
+ Skip any lead whose \`location.pos\` is null \u2014 without lat/lng the widget can't pin it. (Surface them as a "+ N leads without coordinates" footer below the widget instead.)
895
+
896
+ **Notes recipe for each lead** \u2014 "Notes from Claude" on the place card.
897
+
898
+ CRITICAL REALITY OF THE CAROUSEL: it renders the notes string as **a single wrapped paragraph of plain text**. The carousel renderer:
899
+ - STRIPS markdown \u2014 \`[Name](url)\` shows as literal "Name (url)" with the URL visible mid-text;
900
+ - COLLAPSES newlines \u2014 vertical stacking does NOT work;
901
+ - TRUNCATES long URLs mid-string visibly;
902
+ - DOES auto-linkify bare phone numbers (\`+1 212-555-0100\` \u2192 tappable \`tel:\`) and bare email addresses (\`name@company.com\` \u2192 tappable \`mailto:\`) \u2014 those become the only "properties" the user can act on inside the card.
903
+
904
+ So the notes string MUST be short prose with bare-text channels only. Use exactly this shape (one sentence, \u2264 ~30 words):
905
+
906
+ \`\`\`
907
+ \u2605 <One-sentence sector/fit + why-now>. Reach <Contact First Last>, <role>: <bare phone>, <bare email>.
908
+ \`\`\`
909
+
910
+ Examples:
911
+ - \`\u2605 Strongest fit \u2014 active thread, 'trying to reach' from last Friday. Reach Troy Schirk, Principal & CIO: +1 312-550-2382, tschirk@atlasholdingsllc.com.\`
912
+ - \`\u2605 Mid-size HR/staffing match, multi-branch pattern. Reach Irving Enciso, Regional Ops Manager: 952-835-1288, info@employersolutionsgroup.com.\`
913
+
914
+ Rules:
915
+ - ONE sentence. No newlines. No emoji prefixes (\`\u{1F464}\u{1F4DE}\u2709\uFE0F\u{1F310}\` add clutter but do NOT create sections \u2014 the carousel ignores layout).
916
+ - NO markdown links anywhere in \`notes\`. Especially no LinkedIn URLs \u2014 they're long, they mid-truncate visibly, and the carousel renders them as raw text. Save LinkedIn for the chat prose below the widget.
917
+ - Phone + email inline as bare text. They auto-linkify; that's the user's tap target inside the card.
918
+ - Score callout (\`\u2605 Strongest fit\`, \`Score 83\`, etc.) uses \`ai_agent_lead_score\` when present, else \`score\`.
919
+ - Omit channels that aren't enriched yet \u2014 don't write "<no phone>".
920
+
921
+ ## Chat prose AFTER the widget (where markdown DOES render)
922
+
923
+ The carousel is the spatial visual. The user still wants the rich contact detail somewhere \u2014 but the right surface for that is the **chat message after invoking the widget**, not the notes inside it. Chat renders markdown links, lists, emoji properly.
924
+
925
+ Below the widget invocation, emit a short structured summary like:
926
+
927
+ \`\`\`
928
+ **Atlas Holdings \u2014 Far Rockaway.** \u2605 Strongest fit. Active thread, "trying to reach" status from last Friday; AI angle is fresh (recent strategic investment signal).
929
+ Contact: **[Troy Schirk](<linkedin_page or constructed search URL>)**, Principal & CIO \xB7 \u260E +1 312-550-2382 \xB7 \u2709 tschirk@atlasholdingsllc.com
930
+
931
+ **Employer Solutions Services \u2014 Midtown.** \u2605 Mid-size HR/staffing match. Same address as the staffing-group sibling \u2014 single visit covers both.
932
+ Contact: **[Irving Enciso](<linkedin URL>)**, Regional Operations Manager \xB7 \u260E 952-835-1288 \xB7 \u2709 info@employersolutionsgroup.com
933
+ \`\`\`
934
+
935
+ Keep it short \u2014 1 lead per ~3 lines, top 3\u20135 most relevant. The LinkedIn-linked contact name lives here (chat markdown works), the channels are listed as \` \xB7 \`-separated pills. **Do NOT enumerate the same leads as a markdown table** \u2014 this list-form summary is the chat-side detail surface.
936
+
937
+ **LinkedIn URL priority** (used in the chat prose, not in carousel notes):
938
+ 1. \`recommended_contact.linkedin_page\` when set, not the literal \`"null"\`, starts with \`https://\`;
939
+ 2. Constructed search: \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\` with the company name stripped of \`Inc / LLC / Corp / GmbH / Ltd / Co / S.A. / S.L. / PLC / AG / SAS / SARL\` suffixes, URL-encoded. Append a trailing \` \xB0\` to the contact name to mark the fallback path.
940
+
941
+ Open with **one short intro sentence** in chat ("Five lead visits across NYC for your trip next week \u2014 three in Midtown, plus Long Island and one in NJ.") and then invoke the widget, then the chat-side list above. **No markdown table.**
942
+
943
+ **After the widget renders, end the turn with the NEXT STEPS surface** \u2014 not with a prose question. See "GATE \u2014 PREFER BUILT-IN HOST WIDGETS" below: surface 2\u20134 mutually-exclusive moves via \`ask_user_input_v0\` if the host exposes it, else as a short bulleted list. "Want me to plot these on a map or jump to outreach for Atlas?" is exactly the prose pattern to AVOID \u2014 it's a \`single_select\` with two options.
944
+
945
+ ## RENDER \u2014 fallback for hosts without \`places_map_display_v0\`
946
+
947
+ If the host doesn't expose the native map, emit per-lead markdown blocks in this **exact** format \u2014 modern chat hosts (Claude.ai web, cowork) auto-detect addresses + company names and render them as a Google-Place-card carousel anyway:
948
+
949
+ \`\`\`
950
+ ### **<Company Name>** \xB7 <City>, <State or Country>
951
+
952
+ \u2605 <Score callout>. <One-sentence sector fit + why-now>.
953
+
954
+ \u{1F464} [<Contact First Last>](<LinkedIn URL>) \u2014 <role>
955
+ \u{1F4DE} <bare phone>
956
+ \u2709\uFE0F <bare email>
957
+ \u{1F310} <company website>
958
+ \`\`\`
959
+
960
+ Same one-channel-per-line discipline \u2014 newlines between channels so the carousel renders them as scannable properties (vs a wall of prose). Same auto-linkify rules: bare phone, bare email, markdown-wrapped contact name.
961
+
962
+ The response payload carries everything you need: \`lead.company_name\` (or \`name\`), \`lead.location.city / country / full / pos\`, \`lead.score\`, \`lead.ai_agent_lead_score\`, \`lead.recommended_contact.{first_name, last_name, job_title, linkedin_page, email, phone_number}\`, \`lead.phone_numbers[]\`, \`lead.website\`, \`lead.short_description\`, \`lead.last_monitor_action\` + \`last_monitor_action_at\`. Score callout uses \`ai_agent_lead_score\` when present, else \`score\`.
963
+
964
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
965
+
966
+ Modern chat hosts (Claude, ChatGPT) expose first-party widgets the agent can route into. These ALWAYS produce a better UX than markdown tables / inline prose for the data shapes they support \u2014 they're tappable on mobile, persistent across turns, and integrate with the host's quick-actions.
967
+
968
+ **The Big Three** \u2014 when a tool result fits, route there:
969
+
970
+ | Host widget | Use when | Field map (from Leadbay payload) |
971
+ |---|---|---|
972
+ | \`places_map_display_v0\` (Claude) | Result has \u22652 leads with \`location.city\` set, and the user's intent is geographic / "in person" / travel | \`{name: lead.company_name, address: "<city>, <country>", place_id: lead.location.place_id ?? omit, notes: <one-sentence pitch>}\` per location |
973
+ | \`message_compose_v1\` (Claude) | You're about to draft outreach (email / message / call opener) | \`{kind: "email", summary_title, variants: [{label, body, subject}]}\` \u2014 2\u20133 variants, labels describe STRATEGY ("Push for alignment", "Reference the M&A signal"), not tone ("Friendly", "Formal") |
974
+ | \`ask_user_input_v0\` (Claude) | The tool's NEXT STEPS block has 2\u20134 mutually-exclusive next moves and the user hasn't already chosen | \`{questions: [{question: "What next?", type: "single_select", options: [<2-4 short button labels>]}]}\`; max 3 questions per call |
975
+
976
+ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We don't ship any custom widgets ourselves \u2014 this gate is exclusively about routing into the host's first-party widgets when the data shape fits.
977
+
978
+ **Rules:**
979
+ - The widget IS the visual. Do NOT emit a markdown table or prose list of the same data alongside \u2014 that produces two competing UIs.
980
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
981
+ - When the host doesn't expose the named widget, the agent falls back to the prose/table rendering the per-tool description already specifies. The directive is host-conditional; the fallback is automatic.
982
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
983
+
984
+
985
+ ---
986
+
987
+ WHEN TO USE: the user mentions travel, in-person follow-up, "this week's trip", "visiting", "I'm going to", or any phrasing that benefits from a geographic view of pipeline. Also when the user explicitly asks for a map.
988
+
989
+ WHEN NOT TO USE: for the default follow-up table (status badges, AI take, history) \u2014 that's \`leadbay_pull_followups\`. For NEW leads from the Discover wishlist \u2014 that's \`leadbay_pull_leads\`.
990
+
991
+ The response shape is identical to \`leadbay_pull_followups\`: \`{leads, active_filters, pagination, total_excluded_by_pushback, _meta}\` on success; \`{status: "ambiguous_locations", location_ambiguities}\` when a passed \`city\` was ambiguous.
992
+ `;
699
993
  var leadbay_get_clarification = `Check whether Leadbay has a pending clarification question \u2014 a question raised when refining the intelligence prompt produced contradictory or ambiguous criteria. Returns \`{pending: false, clarification: null}\` when nothing is pending (the backend returns 204).
700
994
 
701
995
  WHEN TO USE: after leadbay_refine_prompt, to see if Leadbay needs the user to disambiguate.
@@ -706,7 +1000,7 @@ var leadbay_get_contacts = `Get contacts for a lead, including enriched email an
706
1000
 
707
1001
  WHEN TO USE: to check enrichment status (\`contact.enrichment.done\`) on individual leads after a bulk enrichment was launched, or to find the \`contact_id\` needed by leadbay_enrich_contacts.
708
1002
 
709
- WHEN NOT TO USE: as a substitute for leadbay_research_lead, which already includes enriched contacts in its return.
1003
+ WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id, which already includes enriched contacts in its return.
710
1004
  `;
711
1005
  var leadbay_get_enrichment_job_titles = `List the actual job titles present across the leads currently in the user's selection \u2014 the candidate set the user can ask to enrich.
712
1006
 
@@ -724,7 +1018,7 @@ var leadbay_get_lead_activities = `Get prospecting activity history for a lead (
724
1018
 
725
1019
  WHEN TO USE: to avoid redundant outreach and understand where this lead is in the sales process.
726
1020
 
727
- WHEN NOT TO USE: when leadbay_research_lead has already been called \u2014 it includes recent prospecting actions in its engagement block.
1021
+ WHEN NOT TO USE: when leadbay_research_lead_by_id has already been called \u2014 it includes recent prospecting actions in its engagement block.
728
1022
  `;
729
1023
  var leadbay_get_lead_notes = `Read existing notes on a lead \u2014 context the human team or prior agent runs have already captured.
730
1024
 
@@ -736,7 +1030,7 @@ var leadbay_get_lead_profile = `Get a full lead profile including company detail
736
1030
 
737
1031
  WHEN TO USE: low-level \u2014 for fine-grained access to the raw shape of the lead profile.
738
1032
 
739
- WHEN NOT TO USE: as the agent's default lead-detail tool \u2014 use leadbay_research_lead, which structures the data top-down (qualification first, then signals, then firmographics, then contacts, then engagement) and reshapes web_fetch.content into a stable array form.
1033
+ WHEN NOT TO USE: as the agent's default lead-detail tool \u2014 use leadbay_research_lead_by_id, which structures the data top-down (qualification first, then signals, then firmographics, then contacts, then engagement) and reshapes web_fetch.content into a stable array form.
740
1034
  `;
741
1035
  var leadbay_get_lens_filter = `Read the firmographic filter (sectors, sizes, locations) currently applied to a lens.
742
1036
 
@@ -772,7 +1066,7 @@ var leadbay_get_taste_profile = `Get the user's Ideal Buyer Profile, purchase-in
772
1066
 
773
1067
  WHEN TO USE: at the very start of a session to understand what kind of leads the user is looking for.
774
1068
 
775
- WHEN NOT TO USE: per-lead \u2014 leadbay_research_lead already includes the per-lead qualification answers (which are scored against these org-level questions).
1069
+ WHEN NOT TO USE: per-lead \u2014 leadbay_research_lead_by_id already includes the per-lead qualification answers (which are scored against these org-level questions).
776
1070
  `;
777
1071
  var leadbay_get_user_prompt = `Read the org's intelligence-refinement prompt (free-text instruction that steers lead recommendations beyond firmographics). Returns \`{prompt: null, set: false}\` when none is configured (the backend returns 204 in that case).
778
1072
 
@@ -784,13 +1078,13 @@ var leadbay_get_web_fetch = `Read the AI-generated web-research summary for a le
784
1078
 
785
1079
  WHEN TO USE: when the agent already qualified this lead and wants the underlying research to reason from.
786
1080
 
787
- WHEN NOT TO USE: as the first read on a lead \u2014 the leadbay_research_lead composite bundles this with qualification answers and reshapes the dict into a stable array form.
1081
+ WHEN NOT TO USE: as the first read on a lead \u2014 the leadbay_research_lead_by_id composite bundles this with qualification answers and reshapes the dict into a stable array form.
788
1082
  `;
789
1083
  var leadbay_import_and_qualify = `Import + qualify leads in one call. Pass either \`domains: [{domain, name?}]\` (Mode A) OR \`records[]\` with \`mappings\` (Mode B). At least one mapped field must be LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE. Discover the org's mappable surface via \`leadbay_list_mappable_fields\`. For messy files, prefer the \`leadbay_import_file\` prompt which walks an agent through scan \u2192 resolve \u2192 preserve \u2192 commit phases.
790
1084
 
791
1085
  WHEN TO USE: agent has a list of companies (domains, or CSV-shaped rows from the user's CRM) and wants the full AI qualification \u2014 qualification answers, web-research signals \u2014 without orchestrating import + bulk_qualify_leads + lead_profile chains by hand.
792
1086
 
793
- WHEN NOT TO USE: discovery (use leadbay_pull_leads); single-lead deep dive (use leadbay_research_lead); high-cadence or untrusted automation \u2014 this mutates user state and consumes ai_rescore + web_fetch quota.
1087
+ WHEN NOT TO USE: discovery (use leadbay_pull_leads); single-lead deep dive (use leadbay_research_lead_by_id); high-cadence or untrusted automation \u2014 this mutates user state and consumes ai_rescore + web_fetch quota.
794
1088
 
795
1089
  Budgets: \`total_budget_ms\` caps wall-clock; \`per_lead_budget_ms\` caps each lead's poll. For short transport timeouts, pass \`wait_for_completion:false\` and poll \`leadbay_import_status\`. Outputs \`qualified[]\`, \`still_running[]\`, \`not_imported[]\`, \`qualify_id\` (resumable handle). Idempotent within a 5-min window. \`dry_run:'preview'\` returns mapping hints + custom-field candidates without importing.
796
1090
 
@@ -815,13 +1109,39 @@ The response carries either a completed result or an async handle. Render a brie
815
1109
 
816
1110
  **When the user's request implied a downstream use** ("import then prep outreach for them"), emit \`Imported leadIds: <up to 5 ids, then '+N more'>\` \u2014 just the ids. Let the next composite render the leads.
817
1111
 
818
- Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead\` in NEXT STEPS.
1112
+ Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead_by_id\` in NEXT STEPS.
819
1113
 
820
1114
 
821
1115
  ---
822
1116
 
823
1117
  ## NEXT STEPS \u2014 after an import
824
1118
 
1119
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
1120
+
1121
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
1122
+
1123
+ \`\`\`
1124
+ ask_user_input_v0({
1125
+ questions: [{
1126
+ question: "What next?",
1127
+ type: "single_select",
1128
+ options: [
1129
+ "<Suggest column from row 1>",
1130
+ "<Suggest column from row 2>",
1131
+ "<Suggest column from row 3>"
1132
+ ]
1133
+ }]
1134
+ })
1135
+ \`\`\`
1136
+
1137
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
1138
+
1139
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
1140
+
1141
+ ---
1142
+
1143
+
1144
+
825
1145
  | Observation | Suggest | Calls |
826
1146
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
827
1147
  | Status: running | "Check progress" | leadbay_import_status(handle_id) |
@@ -831,7 +1151,7 @@ Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_res
831
1151
  | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
832
1152
  | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
833
1153
  `;
834
- var leadbay_import_leads = `Import leads into Leadbay's CRM via the file-import wizard. Returns stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead. For MCP clients with short transport timeouts, pass \`wait_for_completion:false\` to return quickly with \`{status:'running', handle_id}\`; poll leadbay_import_status with that handle. For end-to-end import+qualify in one call, prefer leadbay_import_and_qualify. For messy files, prefer the \`leadbay_import_file\` prompt which walks an agent through scan \u2192 resolve \u2192 preserve \u2192 commit phases.
1154
+ var leadbay_import_leads = `Import leads into Leadbay's CRM via the file-import wizard. Returns stable Leadbay leadIds for downstream chaining into leadbay_bulk_qualify_leads / leadbay_research_lead_by_id. For MCP clients with short transport timeouts, pass \`wait_for_completion:false\` to return quickly with \`{status:'running', handle_id}\`; poll leadbay_import_status with that handle. For end-to-end import+qualify in one call, prefer leadbay_import_and_qualify. For messy files, prefer the \`leadbay_import_file\` prompt which walks an agent through scan \u2192 resolve \u2192 preserve \u2192 commit phases.
835
1155
 
836
1156
  TWO MODES: (A) Domain-list shortcut \u2014 pass \`domains: [{domain, name?}]\`. The tool builds a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and imports with the default mapping. (B) Custom records + mapping \u2014 pass \`records: [{Col1, Col2, ...}]\` plus \`mappings.fields: {Col1: 'LEAD_NAME', ...}\`. \`mappings.fields\` must include LEADBAY_ID, CRM_ID, SIREN, LEAD_NAME, or LEAD_WEBSITE (resolver needs at least one identity key). Pass exactly one of \`domains\` / \`records\`. Reserved column \`MCP_ROW_ID\` cannot appear in records/mappings \u2014 the tool injects it for stable reconciliation.
837
1157
 
@@ -839,7 +1159,7 @@ MUTATES USER STATE: each call creates a row in the user's CRM-imports list (visi
839
1159
 
840
1160
  WHEN TO USE: you have a list of company domains from another system (CRM, analytics, email correspondents) and need stable Leadbay leadIds; or CRM-shaped rows with custom columns and want to drive the wizard with explicit field mappings.
841
1161
 
842
- WHEN NOT TO USE: for prospect discovery (use leadbay_pull_leads); for one specific company's profile (use leadbay_research_company); when you can't tolerate the side effects above; when you also want qualification in the same call (use leadbay_import_and_qualify).
1162
+ WHEN NOT TO USE: for prospect discovery (use leadbay_pull_leads); for one specific company's profile (use leadbay_research_lead_by_name_fuzzy); when you can't tolerate the side effects above; when you also want qualification in the same call (use leadbay_import_and_qualify).
843
1163
 
844
1164
  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\`.
845
1165
 
@@ -862,13 +1182,39 @@ The response carries either a completed result or an async handle. Render a brie
862
1182
 
863
1183
  **When the user's request implied a downstream use** ("import then prep outreach for them"), emit \`Imported leadIds: <up to 5 ids, then '+N more'>\` \u2014 just the ids. Let the next composite render the leads.
864
1184
 
865
- Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead\` in NEXT STEPS.
1185
+ Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead_by_id\` in NEXT STEPS.
866
1186
 
867
1187
 
868
1188
  ---
869
1189
 
870
1190
  ## NEXT STEPS \u2014 after an import
871
1191
 
1192
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
1193
+
1194
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
1195
+
1196
+ \`\`\`
1197
+ ask_user_input_v0({
1198
+ questions: [{
1199
+ question: "What next?",
1200
+ type: "single_select",
1201
+ options: [
1202
+ "<Suggest column from row 1>",
1203
+ "<Suggest column from row 2>",
1204
+ "<Suggest column from row 3>"
1205
+ ]
1206
+ }]
1207
+ })
1208
+ \`\`\`
1209
+
1210
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
1211
+
1212
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
1213
+
1214
+ ---
1215
+
1216
+
1217
+
872
1218
  | Observation | Suggest | Calls |
873
1219
  |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
874
1220
  | Status: running | "Check progress" | leadbay_import_status(handle_id) |
@@ -922,6 +1268,42 @@ WHEN TO USE: low-level.
922
1268
 
923
1269
  WHEN NOT TO USE: from agent flow \u2014 leadbay_enrich_titles handles selection lifecycle, preview, launch, and cleanup.
924
1270
 
1271
+ 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\`.
1272
+ `;
1273
+ var leadbay_like_lead = `## WHEN TO USE
1274
+
1275
+ Trigger phrases: "I like this lead", "thumbs up", "this one looks good", "save this one", "this is a good fit", "more like this", "yes to this one".
1276
+
1277
+ Do NOT use for: "remind me about this lead later / snooze it" \u2192 \`leadbay_set_pushback\`; "not relevant / wrong fit / thumbs down" \u2192 \`leadbay_dislike_lead\`.
1278
+
1279
+ Prefer when: user expresses durable positive interest in a specific lead; pass the lead's UUID as \`lead_id\`
1280
+
1281
+ Examples that SHOULD invoke this tool:
1282
+ - "I like this lead \u2014 show me more like it."
1283
+ - "Thumbs up on Acme Corp, save it."
1284
+ - "This one's a perfect fit, keep them in the rotation."
1285
+
1286
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
1287
+ - "Skip this for now, I'll look at it next week."
1288
+ - "Not relevant \u2014 wrong industry."
1289
+ - "Just show me the top leads today."
1290
+
1291
+ ## RENDER (quick)
1292
+
1293
+ One short confirmation line after the call returns ("\u{1F44D} Liked **{company_name}** \u2014
1294
+ positive signal sent."), then continue the current flow (don't dump the full
1295
+ lead card again).
1296
+
1297
+ ---
1298
+
1299
+ Mark a lead as liked. This is the same action as clicking the thumbs-up on the Leadbay website. The signal is fed back into the scoring engine: liked leads improve the quality of future batches by teaching Leadbay what the user finds relevant.
1300
+
1301
+ Pass the lead's UUID as \`lead_id\`.
1302
+
1303
+ WHEN TO USE: the user explicitly approves of a lead ("this one looks good", "I like this", "thumbs up", "save this one", "this is a good fit"). Also use after \`leadbay_research_lead\` reveals strong signals.
1304
+
1305
+ WHEN NOT TO USE: the user is just reading or researching a lead without expressing approval. Use \`leadbay_dislike_lead\` for negative signals; use \`leadbay_set_pushback\` for "remind me later" / temporary snooze (like is durable; pushback is reversible).
1306
+
925
1307
  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\`.
926
1308
  `;
927
1309
  var leadbay_list_lenses = `List all available Leadbay lenses (saved lead-search configurations). Each lens defines a different target market or buyer segment. The lens with \`is_last_active=true\` is used by default for lead discovery.
@@ -930,6 +1312,14 @@ WHEN TO USE: when the user wants to switch lens or asks "what lenses do I have".
930
1312
 
931
1313
  WHEN NOT TO USE: in normal flow \u2014 composites auto-resolve the active lens via \`/me.last_requested_lens\`.
932
1314
  `;
1315
+ var leadbay_list_locations = `Search the geo / admin-area taxonomy by free-text name and return the matching admin_area ids. This is the primary way to turn a user's "leads in Berlin" / "filter to Lyon" intent into the \`{type: "location_ids", locations: [<id>]}\` shape that the backend filter expects.
1316
+
1317
+ The response has two arrays: \`results\` (top-10 prefix matches ranked by relevance) and \`parents\` (the admin-area chain referenced by \`results[].parent_ids\`, useful for disambiguation breadcrumbs). Each entry: \`{id, country, level, name, parent_ids}\`. The \`level\` is the admin depth \u2014 **5** = region, **6** = county, **7** = township-area, **8** = city/town.
1318
+
1319
+ WHEN TO USE: to resolve a free-text city/region name before passing it to a \`location_ids\` filter (e.g. on \`leadbay_pull_followups({set_filter})\` or \`leadbay_adjust_audience\`). The composite \`leadbay_pull_followups\` accepts \`city: <free-text>\` directly and runs this resolver internally \u2014 prefer that path; reach for this granular tool only when you need to surface candidates to the user before committing.
1320
+
1321
+ WHEN NOT TO USE: when you already have an admin_area id \u2014 pass it as \`city_id\` (composite path) or directly inside the FilterCriterion.
1322
+ `;
933
1323
  var leadbay_list_mappable_fields = `List every CRM field the agent can target when calling leadbay_import_leads or leadbay_import_and_qualify. Returns two arrays: \`standard_fields\` (Leadbay's built-in StandardCrmFieldType enum \u2014 LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, contact + location + sector fields) and \`custom_fields\` (this org's user-defined fields \u2014 id, name, type, and the literal \`mapping_value\` you pass in \`mappings.fields\`). For custom fields, \`mapping_value\` is the wire-format string \`CUSTOM.<id>\` \u2014 pass it verbatim.
934
1324
 
935
1325
  For contact exports, map person data to CONTACT_* fields and still provide parent-company identity via LEADBAY_ID/LEAD_WEBSITE/LEAD_NAME/CRM_ID/SIREN. When contact emails contain business domains, agents may derive a clean company-domain column for LEAD_WEBSITE only when the domain agrees with the row's company/deal/brand context, while preserving the original email as CONTACT_EMAIL. For import files, audit every meaningful source column. If no standard/contact field fits, preserve the data by creating or reusing a custom field unless the column is blank, duplicate plumbing, raw unparsed noise after useful extraction, or harmful to data quality. For HubSpot or other source-system deep links, create or reuse an EXTERNAL_ID/TEXT custom field with leadbay_create_custom_field, then map the source id/link to the returned \`mapping_value\`. Backend mapping_hints are advisory only; for contact files, do not accept hints such as first_name -> LEAD_NAME when the column is clearly a person field.
@@ -982,6 +1372,17 @@ WHEN NOT TO USE: if a token is already preconfigured \u2014 you'll just overwrit
982
1372
 
983
1373
  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\`.
984
1374
  `;
1375
+ var leadbay_open_billing_portal = `Generate a one-shot Stripe customer-portal URL. Wraps \`GET /1.5/stripe/portal\` \u2192 \`{url}\`. Returns a fresh Stripe-hosted URL the user can open to manage their existing Leadbay subscription: change plan tier, swap payment method, view invoices. The agent does NOT make subscription changes itself \u2014 it surfaces the URL and lets the user act.
1376
+
1377
+ Sibling of \`leadbay_create_topup_link\`. Use cases differ:
1378
+
1379
+ - **Top-up** (\`leadbay_create_topup_link\`) = one-shot purchase of AI credits to clear an immediate quota throttle. Doesn't change the plan.
1380
+ - **Billing portal** (this tool) = ongoing subscription management. Plan upgrades persist; they raise the recurring window caps (daily/weekly/monthly) rather than buying a one-time bucket.
1381
+
1382
+ WHEN TO USE: when the user asks to upgrade / downgrade / change plan; manage payment methods; view invoices; or "open my billing".
1383
+
1384
+ WHEN NOT TO USE: for one-off credit top-ups (use \`leadbay_create_topup_link\`); for read-only quota state (use \`leadbay_account_status\` / \`leadbay_get_quota\`).
1385
+ `;
985
1386
  var leadbay_pick_clarification = `Answer the pending clarification question \u2014 either by picking one of the offered options (\`option_id\`) or by typing a free-text answer. The answer is stored as the new user_prompt and triggers regeneration. Admin-only.
986
1387
 
987
1388
  WHEN TO USE: low-level.
@@ -990,7 +1391,36 @@ WHEN NOT TO USE: from agent flow \u2014 use leadbay_answer_clarification.
990
1391
 
991
1392
  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\`.
992
1393
  `;
993
- var leadbay_prepare_outreach = `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\` (always in the post-enrichment shape \u2014 \`contact_id\`, \`first_name\`, \`last_name\`, \`job_title\`, \`email\`, \`phone_number\`, \`linkedin_page\`, \`is_org_contact\` \u2014 with nulls where data isn't yet enriched), \`additional_contacts_count\` (other contacts at this company), and an \`enrichment\` block describing async state.
1394
+ var leadbay_prepare_outreach = `## WHEN TO USE
1395
+
1396
+ Trigger phrases: "draft outreach for <Contact>", "write an email to <Contact>", "prep me to call <Contact>", "outreach package for <Company>", "I'm about to reach out to <X>".
1397
+
1398
+ Do NOT use for: "research a lead I haven't picked yet" \u2192 \`leadbay_research_lead_by_id\`; "log outreach I already sent" \u2192 \`leadbay_report_outreach\`; "enrich a batch of contacts" \u2192 \`leadbay_enrich_titles\`.
1399
+
1400
+ Prefer when: user is about to act \u2014 has picked the lead AND the contact. Single-lead, action-imminent context
1401
+
1402
+ Examples that SHOULD invoke this tool:
1403
+ - "Draft an email to Sarah at Acme."
1404
+ - "Help me write outreach for John Smith."
1405
+ - "I'm about to call Acme's CTO \u2014 prep me."
1406
+
1407
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
1408
+ - "I just sent that email \u2014 log it."
1409
+ - "Enrich the contacts on these 20 leads."
1410
+ - "Research the Acme lead before I commit."
1411
+
1412
+ ## RENDER (quick)
1413
+
1414
+ Route the draft through \`message_compose_v1\` (Claude's email composer)
1415
+ with 2\u20133 strategic variants \u2014 labels describe STRATEGY, not tone
1416
+ ("Push for alignment", "Reference M&A signal", "Soft intro \u2014 peer
1417
+ reference"). Above the composer, emit ONE short markdown paragraph
1418
+ with score callout + sector fit + linked contact name + bare phone /
1419
+ email. Do NOT paste the email body into chat prose alongside.
1420
+
1421
+ ---
1422
+
1423
+ 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\` (always in the post-enrichment shape \u2014 \`contact_id\`, \`first_name\`, \`last_name\`, \`job_title\`, \`email\`, \`phone_number\`, \`linkedin_page\`, \`is_org_contact\` \u2014 with nulls where data isn't yet enriched), \`additional_contacts_count\` (other contacts at this company), and an \`enrichment\` block describing async state.
994
1424
 
995
1425
  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\`.
996
1426
 
@@ -1001,7 +1431,73 @@ IRON LAW \u2014 OUTCOME AFTER OUTREACH. The moment the user reports outreach hap
1001
1431
 
1002
1432
  WHEN TO USE: when the agent is about to draft outreach for ONE specific lead and needs everything to compose \u2014 channels + angles + history context.
1003
1433
 
1004
- WHEN NOT TO USE: across many leads \u2014 use leadbay_enrich_titles for bulk; for general lead detail use leadbay_research_lead (richer signals); to actually log the outreach action use leadbay_report_outreach (requires verification).
1434
+ WHEN NOT TO USE: across many leads \u2014 use leadbay_enrich_titles for bulk; for general lead detail use leadbay_research_lead_by_id (richer signals); to actually log the outreach action use leadbay_report_outreach (requires verification).
1435
+
1436
+ ---
1437
+
1438
+ ## RENDER \u2014 host-native message composer is the PRIMARY surface
1439
+
1440
+ \`message_compose_v1\` is the canonical surface for outreach drafts on this tool. The composer gives the user inline edit + send affordances and beats any prose code-fenced draft. **If the host exposes \`message_compose_v1\`, route every draft through it.** Don't paste email body or call-opener body into chat prose alongside \u2014 the composer IS the visual.
1441
+
1442
+ Above the composer, emit ONE short markdown context paragraph: the lead's score callout + sector fit + recommended-contact name (LinkedIn-markdown-linked) + bare phone/email pills. That gives the user the "why this lead" context. The composer below carries the actionable draft.
1443
+
1444
+ **Single draft:**
1445
+
1446
+ \`\`\`
1447
+ message_compose_v1({
1448
+ kind: "email",
1449
+ summary_title: "Outreach to <Contact Name> at <Company>",
1450
+ variants: [{
1451
+ label: "Lead with the M&A signal",
1452
+ subject: "<one-line subject \u2014 references the angle>",
1453
+ body: "<5-8 sentence email; salesperson voice; references signal + a clear next step>"
1454
+ }]
1455
+ })
1456
+ \`\`\`
1457
+
1458
+ **Strategic options (preferred when split_ai_summary surfaces multiple angles):**
1459
+
1460
+ \`\`\`
1461
+ message_compose_v1({
1462
+ kind: "email",
1463
+ summary_title: "Three angles for <Company> outreach",
1464
+ variants: [
1465
+ { label: "Push for alignment", subject: "...", body: "..." },
1466
+ { label: "Reference the M&A signal", subject: "...", body: "..." },
1467
+ { label: "Soft intro \u2014 peer reference", subject: "...", body: "..." }
1468
+ ]
1469
+ })
1470
+ \`\`\`
1471
+
1472
+ Constraints:
1473
+ - **Labels describe STRATEGY, not tone.** "Push for alignment", "Reference M&A signal", "Lead with peer reference" \u2014 not "Friendly" / "Formal" / "Aggressive".
1474
+ - **2\u20133 variants when strategic options are clearly distinct.** One variant when you have a single best-angle draft.
1475
+ - Subject required for \`kind: "email"\`. Phone/call openers use \`kind: "other"\` with the opener in \`body\`.
1476
+
1477
+ The composer becomes the single visual. **Don't also paste the email body into chat prose** \u2014 that's just noise next to the composer.
1478
+
1479
+ For phone-only contacts (no email enriched), use \`kind: "other"\` with a 60-second call opener.
1480
+
1481
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
1482
+
1483
+ Modern chat hosts (Claude, ChatGPT) expose first-party widgets the agent can route into. These ALWAYS produce a better UX than markdown tables / inline prose for the data shapes they support \u2014 they're tappable on mobile, persistent across turns, and integrate with the host's quick-actions.
1484
+
1485
+ **The Big Three** \u2014 when a tool result fits, route there:
1486
+
1487
+ | Host widget | Use when | Field map (from Leadbay payload) |
1488
+ |---|---|---|
1489
+ | \`places_map_display_v0\` (Claude) | Result has \u22652 leads with \`location.city\` set, and the user's intent is geographic / "in person" / travel | \`{name: lead.company_name, address: "<city>, <country>", place_id: lead.location.place_id ?? omit, notes: <one-sentence pitch>}\` per location |
1490
+ | \`message_compose_v1\` (Claude) | You're about to draft outreach (email / message / call opener) | \`{kind: "email", summary_title, variants: [{label, body, subject}]}\` \u2014 2\u20133 variants, labels describe STRATEGY ("Push for alignment", "Reference the M&A signal"), not tone ("Friendly", "Formal") |
1491
+ | \`ask_user_input_v0\` (Claude) | The tool's NEXT STEPS block has 2\u20134 mutually-exclusive next moves and the user hasn't already chosen | \`{questions: [{question: "What next?", type: "single_select", options: [<2-4 short button labels>]}]}\`; max 3 questions per call |
1492
+
1493
+ ChatGPT exposes the same routing pattern via \`_meta.openai/outputTemplate\`. We don't ship any custom widgets ourselves \u2014 this gate is exclusively about routing into the host's first-party widgets when the data shape fits.
1494
+
1495
+ **Rules:**
1496
+ - The widget IS the visual. Do NOT emit a markdown table or prose list of the same data alongside \u2014 that produces two competing UIs.
1497
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
1498
+ - When the host doesn't expose the named widget, the agent falls back to the prose/table rendering the per-tool description already specifies. The directive is host-conditional; the fallback is automatic.
1499
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
1500
+
1005
1501
 
1006
1502
  ---
1007
1503
 
@@ -1029,7 +1525,7 @@ Present as the richest single-record card the MCP emits. The user is seconds-to-
1029
1525
  **H5: \u{1F3AF} Angles & approach**
1030
1526
 
1031
1527
  - Render \`lead.split_ai_summary.approach_angle\` as the lead-in.
1032
- - 3\u20134 bullets distilling \`split_ai_summary.next_step\` and any signals from a prior \`research_company\` call into salesperson-voice talking points. Cite \`[source](url)\` inline when known.
1528
+ - 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.
1033
1529
  - 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).
1034
1530
 
1035
1531
  **H5: \u{1F4DC} History with [Contact name]**
@@ -1042,7 +1538,7 @@ Same shape as the contact history, but only include items NOT duplicated from th
1042
1538
 
1043
1539
  **H5: \u{1F465} Other contacts** (only if \`additional_contacts_count > 0\`)
1044
1540
 
1045
- One line: \`+N more contacts at this company \u2014 [see them all](leadbay_research_company)\`.
1541
+ One line: \`+N more contacts at this company \u2014 [see them all](leadbay_research_lead_by_id)\`.
1046
1542
 
1047
1543
  **Closing line** (when enrichment is in progress): \`*Enrichment running \u2014 I'll refresh once email/phone lands.*\`
1048
1544
 
@@ -1078,6 +1574,32 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
1078
1574
 
1079
1575
  ## NEXT STEPS \u2014 after the outreach brief
1080
1576
 
1577
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
1578
+
1579
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
1580
+
1581
+ \`\`\`
1582
+ ask_user_input_v0({
1583
+ questions: [{
1584
+ question: "What next?",
1585
+ type: "single_select",
1586
+ options: [
1587
+ "<Suggest column from row 1>",
1588
+ "<Suggest column from row 2>",
1589
+ "<Suggest column from row 3>"
1590
+ ]
1591
+ }]
1592
+ })
1593
+ \`\`\`
1594
+
1595
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
1596
+
1597
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
1598
+
1599
+ ---
1600
+
1601
+
1602
+
1081
1603
  Offer 2\u20133 follow-ups. Choose based on enrichment state + available channels + history. Always offer the "log outreach" option once the user has clearly contacted someone.
1082
1604
 
1083
1605
  | Observation | Suggest | Calls |
@@ -1088,7 +1610,7 @@ Offer 2\u20133 follow-ups. Choose based on enrichment state + available channels
1088
1610
  | LinkedIn URL available | "Draft the LinkedIn DM" | (agent self-drafts inline) |
1089
1611
  | Only company line, no direct phone | "Draft a switchboard script targeting [Contact]" | (agent self-drafts; flag uncertainty) |
1090
1612
  | \`additional_contacts_count > 0\` | "Show me the other N contacts at this company" | leadbay_get_contacts(leadId) |
1091
- | History is empty | "Pull the strategic overview before drafting" | leadbay_research_company(leadId) |
1613
+ | History is empty | "Pull the strategic overview before drafting" | leadbay_research_lead_by_id(leadId) |
1092
1614
  | User reports they reached out | "Log this outreach \u2014 creates prospecting action + outcome" | leadbay_report_outreach(leadId, contact_id, ...) |
1093
1615
  | User adds context for next time | "Save a note on the contact or company" | leadbay_add_note |
1094
1616
  | After a successful exchange | "Update qualification answers based on what you learned" | leadbay_answer_clarification |
@@ -1109,7 +1631,36 @@ WHEN NOT TO USE: as a non-admin (will fail with 403); for personal lens changes
1109
1631
 
1110
1632
  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\`.
1111
1633
  `;
1112
- var leadbay_pull_followups = `Pull KNOWN leads from the user's Monitor view \u2014 the re-engagement entry point of the two-entry-point workflow. Use when the user asks "what should I follow up on", "leads I haven't contacted", "leads in [city]", "before my trip", "this month", "what's overdue", or any phrasing that implies pre-existing pipeline context. For NEW leads from the Discover wishlist, use \`leadbay_pull_leads\` instead.
1634
+ var leadbay_pull_followups = `## WHEN TO USE
1635
+
1636
+ Trigger phrases: "what should I follow up on", "leads I've already worked", "leads I haven't contacted", "what's overdue", "stale leads", "before my trip", "this month", "what's going on with my pipeline", "leads in <city / state / country>".
1637
+
1638
+ Do NOT use for: "show me leads / new leads / today's prospects" \u2192 \`leadbay_pull_leads\`; "I'm going to <city> / in person / trip" \u2192 \`leadbay_followups_map\`; "Texas / California / France etc. \u2014 place names go in \`city\`, NOT \`keywords\`" \u2192 \`leadbay_pull_followups\`.
1639
+
1640
+ Prefer when: user names a city / sector / recency window \u2192 pass \`city\` (or \`set_filter\`) so the result is narrowed AND persists for the next session
1641
+
1642
+ Examples that SHOULD invoke this tool:
1643
+ - "What should I follow up on this week?"
1644
+ - "Which leads have I not contacted in 30 days?"
1645
+ - "What's overdue in my pipeline?"
1646
+
1647
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
1648
+ - "Show me today's new leads."
1649
+ - "I'm flying to Berlin Thursday \u2014 who can I meet?"
1650
+ - "Draft an email to Sarah at Acme."
1651
+
1652
+ ## RENDER (quick)
1653
+
1654
+ 4-col markdown table sorted by \`last_monitor_action_at\` desc, NO
1655
+ score bar (status badges instead). Col 1 = status (\u{1F3AF}\u26A1\u{1F7E2}\u{1F4A4}\u2728\u{1F525}\u2744) +
1656
+ company link + location/size. Col 2 = AI take (3 lines from
1657
+ \`split_ai_summary\`). Col 3 = history + notes. Col 4 = contacts
1658
+ (\u2605 recommended, \u260E \u{1F4E7} pills). Active-filters chip line ABOVE the
1659
+ table. Detail + status priority below.
1660
+
1661
+ ---
1662
+
1663
+ Pull KNOWN leads from the user's Monitor view \u2014 the re-engagement entry point. Use when the user asks "what should I follow up on", "leads I haven't contacted", "leads in [city]", "before my trip", or any phrasing implying pre-existing pipeline context. For NEW leads from Discover, use \`leadbay_pull_leads\`.
1113
1664
 
1114
1665
  Backend: wraps \`GET /1.5/monitor?personal=&liked=&filtered=&count=&page=\` plus, when \`set_filter\` is supplied, a preceding \`POST /1.5/monitor/filter\` to persist the filter server-side. The Monitor filter is a single \`FilterItem\` per user \u2014 refreshing the page restores it.
1115
1666
 
@@ -1126,7 +1677,9 @@ Practical mapping from user phrasing to criterion:
1126
1677
  | "leads 50\u2013200 employees" | \`{type: "size", sizes: [{min: 50, max: 200}]}\` |
1127
1678
  | "Y Combinator companies" | \`{type: "yc"}\` |
1128
1679
 
1129
- Geo filtering requires \`admin_area_id\` resolution (the backend doesn't accept free-text city names in \`location_ids\`). The MCP doesn't expose an admin-area lookup yet \u2014 for now, ask the user to pick the geo from the Leadbay app's filter UI, or skip the geo filter and rely on agent post-filtering of the response.
1680
+ 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\`. For multi-city cases, call \`leadbay_list_locations\` then pass \`set_filter.criteria\` with \`{type: "location_ids", is_excluded: false, locations: [...]}\`.
1681
+
1682
+ **Place names go through \`city\`, NEVER \`keywords\`.** This includes any geographic token the user names \u2014 cities (\`"Berlin"\`, \`"NYC"\`), states / provinces / regions (\`"Texas"\`, \`"California"\`, \`"Bavaria"\`), countries (\`"France"\`, \`"United States"\`), neighborhoods (\`"Brooklyn"\`, \`"SoHo"\`). The \`/geo/search\` resolver handles all admin levels \u2014 level 4 (state) and level 2 (country) resolve just as well as level 5 (city). If you put \`"Texas"\` in \`keywords\` you get a TEXT-MATCH against company descriptions (\u22480 hits) instead of a real state filter. If a place name resolves ambiguously, surface the choices to the user \u2014 do NOT silently fall back to keyword search or to the unfiltered Monitor view. If \`keywords: ["Texas"]\` returned empty, the next call is \`city: "Texas"\`, not \`keywords: []\`.
1130
1683
 
1131
1684
  **Pushback exclusion.** Leads with active pushback (\`pushback_status\` set and \`pushback_until > today\`) are excluded from the response. The composite enforces this client-side; \`total_excluded_by_pushback\` in the output reports how many rows were dropped.
1132
1685
 
@@ -1134,7 +1687,7 @@ WHEN TO USE: re-engaging pipeline ("what should I follow up on", "stale leads"),
1134
1687
 
1135
1688
  WHEN NOT TO USE: for NEW leads \u2014 that's \`leadbay_pull_leads\` (Discover).
1136
1689
 
1137
- **Anti-confusion guardrail.** If you're iterating \`pull_leads\` pages looking for rows with \`prospecting_actions_count > 0\` or \`notes_count > 0\`, STOP \u2014 wrong entry point. The two read different backend tables; a lead with follow-up history may not appear in \`pull_leads\` (already aged out of the new-leads queue). Call \`pull_followups\` instead.
1690
+ **Anti-confusion guardrail.** Iterating \`pull_leads\` pages looking for \`prospecting_actions_count > 0\` or \`notes_count > 0\` rows is the wrong entry point \u2014 the two read different tables. Leads with follow-up history live in \`pull_followups\`.
1138
1691
 
1139
1692
  ---
1140
1693
 
@@ -1227,6 +1780,32 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
1227
1780
 
1228
1781
  ## NEXT STEPS \u2014 after the follow-ups table
1229
1782
 
1783
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
1784
+
1785
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
1786
+
1787
+ \`\`\`
1788
+ ask_user_input_v0({
1789
+ questions: [{
1790
+ question: "What next?",
1791
+ type: "single_select",
1792
+ options: [
1793
+ "<Suggest column from row 1>",
1794
+ "<Suggest column from row 2>",
1795
+ "<Suggest column from row 3>"
1796
+ ]
1797
+ }]
1798
+ })
1799
+ \`\`\`
1800
+
1801
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
1802
+
1803
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
1804
+
1805
+ ---
1806
+
1807
+
1808
+
1230
1809
  Always include at least one filter-modification offer (users think in filters: by city, by recency, by action type). Filter modification goes through \`set_filter: FilterItem\` which the composite POSTs to \`/monitor/filter\` server-side.
1231
1810
 
1232
1811
  | Observation | Suggest | Calls |
@@ -1236,16 +1815,47 @@ Always include at least one filter-modification offer (users think in filters: b
1236
1815
  | \`pagination.has_more == true\` | "Pull the next page" | leadbay_pull_followups(page = current + 1) |
1237
1816
  | \u22653 rows \u2728 (never-touched) | "Surface only never-touched leads" | set_filter with \`last_action_date.last_days = 0\` |
1238
1817
  | \u22653 rows \u26A1 (Trying to reach) | "Focus on overdue commitments" | set_filter with \`last_action.types = ["EPILOGUE_COULD_NOT_REACH_STILL_TRYING"]\` |
1239
- | User planning a trip / in a city | "Group by city for trip planning" | set_filter with \`location_ids\` (requires admin_area_id) |
1818
+ | User planning a trip / in a city | "Group by city for trip planning" | leadbay_pull_followups({city: "<their city>"}) \u2014 composite resolves admin_area_id via /geo/search |
1240
1819
  | All rows last action > 60d | "Re-qualify \u2014 context may have changed" | leadbay_bulk_qualify_leads([leadId, ...]) |
1241
- | One obvious priority row | "Take me to that lead's full brief" | leadbay_prepare_outreach(leadId) / leadbay_research_lead(leadId) |
1820
+ | One obvious priority row | "Take me to that lead's full brief" | leadbay_prepare_outreach(leadId) / leadbay_research_lead_by_id(leadId) |
1242
1821
  | User wants to defer a lead | "Snooze [Company] for 3 / 6 / 12 months" | leadbay_set_pushback({ lead_ids:[leadId], status:"3" }) |
1243
1822
  | User completed outreach mid-flow | "Log the outreach + record the outcome" | leadbay_report_outreach |
1244
1823
  | Discovery mode might fit better | "Looking for NEW leads instead? Switch to discovery." | leadbay_pull_leads |
1245
1824
 
1246
1825
  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.
1247
1826
  `;
1248
- var leadbay_pull_leads = `Pull up new leads from the user's last-active lens \u2014 the canonical "show me today's prospects" tool. 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.
1827
+ var leadbay_pull_leads = `## WHEN TO USE
1828
+
1829
+ Trigger phrases: "show me leads", "today's prospects", "what's new today", "let's prospect", "best new leads", "my batch".
1830
+
1831
+ Do NOT use for: "leads I should follow up with" \u2192 \`leadbay_pull_followups\`; "leads I've already worked" \u2192 \`leadbay_pull_followups\`; "I'm going to <city>" \u2192 \`leadbay_followups_map\`; "in person" \u2192 \`leadbay_followups_map\`.
1832
+
1833
+ Prefer when: user has named a specific lens \u2014 pass \`lensId\` and pin it for the rest of the session
1834
+
1835
+ Examples that SHOULD invoke this tool:
1836
+ - "Show me today's leads."
1837
+ - "What's in my inbox this morning?"
1838
+ - "Pull my best new prospects."
1839
+
1840
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
1841
+ - "Which leads should I follow up with this week?"
1842
+ - "Show leads I've already contacted."
1843
+ - "I'm flying to Berlin Thursday \u2014 who should I meet?"
1844
+
1845
+ ## RENDER (quick)
1846
+
1847
+ 3-col markdown table sorted by \`score\` desc \u2014 DO NOT print the numeric
1848
+ score. Col 1 = inline-code 10-segment bar (\`\u25B0\` firmographic, \`\u2756\` AI
1849
+ booster cap at the right end of the filled run, \`\u25B1\` empty;
1850
+ filled=round(score/10), ai=round(avg_boost/3.3)) + \`<br>\` + linked
1851
+ company \xB7 location \xB7 size. Col 2 = why-fits \u226420 words. Col 3 = linked
1852
+ contact + title. Full algorithm + linking rules below.
1853
+
1854
+ ---
1855
+
1856
+ Pull up new leads from the user's last-active lens \u2014 the canonical "show me today's prospects" tool.
1857
+
1858
+ 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.
1249
1859
 
1250
1860
  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.
1251
1861
 
@@ -1300,7 +1910,7 @@ If \`qualification_summary.answered == 0\` or \`avg_qualification_boost\` is nul
1300
1910
  **Column 2 \u2014 Why it fits**
1301
1911
 
1302
1912
  - One sentence, \u2264 20 words.
1303
- - Synthesize from (in priority order, whichever is present) the lead's \`short_description\`, top 2 \`tags[].display_name\`, and the gist of \`qualification_summary.best_response_excerpt\`. The trim payload does NOT carry the longer \`description\` field \u2014 for that, agent must call \`leadbay_research_lead\` or \`leadbay_research_company\`.
1913
+ - Synthesize from (in priority order, whichever is present) the lead's \`short_description\`, top 2 \`tags[].display_name\`, and the gist of \`qualification_summary.best_response_excerpt\`. The trim payload does NOT carry the longer \`description\` field \u2014 for that, agent must call \`leadbay_research_lead_by_id\` or \`leadbay_research_lead_by_name_fuzzy\`.
1304
1914
  - Do NOT append \`(boost N)\` \u2014 the \u2756 cap in column 1 already carries that signal.
1305
1915
  - No bullet lists, no line breaks inside the cell.
1306
1916
 
@@ -1340,14 +1950,40 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
1340
1950
 
1341
1951
  ## NEXT STEPS \u2014 after rendering the pull_leads table
1342
1952
 
1343
- Pick 2\u20133 items below based on what was actually observed in the response. Surface them as a short bulleted list \u2014 do NOT recite the whole table.
1953
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
1954
+
1955
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
1956
+
1957
+ \`\`\`
1958
+ ask_user_input_v0({
1959
+ questions: [{
1960
+ question: "What next?",
1961
+ type: "single_select",
1962
+ options: [
1963
+ "<Suggest column from row 1>",
1964
+ "<Suggest column from row 2>",
1965
+ "<Suggest column from row 3>"
1966
+ ]
1967
+ }]
1968
+ })
1969
+ \`\`\`
1970
+
1971
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
1972
+
1973
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
1974
+
1975
+ ---
1976
+
1977
+
1978
+
1979
+ Pick 2\u20133 items below based on what was actually observed in the response. The table is the source of truth for which moves are valid.
1344
1980
 
1345
1981
  | Observation | Suggest | Calls |
1346
1982
  |------------------------------------------------------------|--------------------------------------------------------------|--------------------------------------------------------|
1347
1983
  | \`has_more == true\` | "Pull the next page (page N+1 of M)" | leadbay_pull_leads(page = current + 1, lensId = pinned)|
1348
1984
  | \u2265 3 rows have \`qualification_summary.answered == 0\` | "Deepen AI qualification on the rows without \u2756 caps" | leadbay_bulk_qualify_leads(leadIds=[\u2026]) |
1349
- | User points at a single row | "Research [Company] in depth" | leadbay_research_lead(leadId) |
1350
- | User wants the company-level web view | "Pull the company-level research for [Company]" | leadbay_research_company({leadId} or {companyName}) |
1985
+ | User points at a single row | "Research [Company] in depth" | leadbay_research_lead_by_id(leadId) |
1986
+ | User only has a name (no leadId in context) | "Look up [Company] by name" | leadbay_research_lead_by_name_fuzzy(companyName) |
1351
1987
  | Top row has phone AND email | "Prepare an outreach for [Contact] \u2014 call + email" | leadbay_prepare_outreach(leadId) |
1352
1988
  | Top row has email but no phone | "Draft an outreach email for [Contact]" | leadbay_prepare_outreach(leadId) |
1353
1989
  | Top row has phone but no email | "Show [Contact]'s call details + a 60-second opener" | leadbay_prepare_outreach(leadId) |
@@ -1369,7 +2005,7 @@ var leadbay_qualify_status = `Retrieve the current state of an import_and_qualif
1369
2005
 
1370
2006
  WHEN TO USE: after leadbay_import_and_qualify or leadbay_bulk_qualify_leads returned a \`qualify_id\` with non-empty \`still_running[]\`, call this tool a few minutes later (or hours) to retrieve the now-completed qualifications without re-running the import or re-spending qualify quota.
1371
2007
 
1372
- WHEN NOT TO USE: as a substitute for leadbay_research_lead \u2014 that's a deeper per-lead profile and includes contacts. This tool is purely the qualification answers + signals_count.
2008
+ WHEN NOT TO USE: as a substitute for leadbay_research_lead_by_id \u2014 that's a deeper per-lead profile and includes contacts. This tool is purely the qualification answers + signals_count.
1373
2009
  `;
1374
2010
  var leadbay_recall_ordered_titles = `Show job titles the org has previously enriched, so the agent can repeat the same titles for new leads (or skip already-saturated ones). Two implementation paths: (1) PREFERRED \u2014 a selection-scoped preview call that reads \`previously_enriched_titles\` from the backend (newer prod field). (2) FALLBACK \u2014 live aggregation across each lead's enriched contacts. The composite picks transparently.
1375
2011
 
@@ -1413,13 +2049,64 @@ WHEN NOT TO USE: BEFORE doing the outreach (use \`dry_run:true\` to validate arg
1413
2049
 
1414
2050
  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\`.
1415
2051
  `;
1416
- var leadbay_research_company = `Deep-dive research on a specific company by NAME (fuzzy match against the active lens's wishlist). Pass \`companyName\` (matches the top-scoring lead with that name) or \`leadId\` (takes precedence when both supplied).
2052
+ var leadbay_research_lead_by_id = `## WHEN TO USE
2053
+
2054
+ Trigger phrases: "tell me about this lead", "deep dive on the lead I just picked", "is <lead> a fit", "should I pursue <lead>", "everything you know about lead <UUID>", "research this lead".
1417
2055
 
1418
- The response carries the rich-lead block (firmographics, \`phone_numbers\`, \`split_ai_summary\`, \`social_urls\`), \`qualification[]\` (Q&A pairs from the AI agent \u2014 empty until the lead is qualified), \`contacts[]\` (paid + org, each with a normalized \`linkedin_page\`), \`web_insights\` (keyed by emoji-prefixed section labels \u2014 see RENDERING for handling), \`web_insights_fetched_at\` (staleness), and \`recent_activities\` (engagement history).
2056
+ Do NOT use for: "user names a company in prose without a Leadbay lead id" \u2192 \`leadbay_research_lead_by_name_fuzzy\`; "draft outreach for <Contact>" \u2192 \`leadbay_prepare_outreach\`.
1419
2057
 
1420
- WHEN TO USE: when the user references a company by name and you don't yet have its \`lead_id\`, OR when you want a single-record card with web-research signals (business signals, prospecting clues, strategic positioning) rather than a raw lead profile.
2058
+ Prefer when: user already picked a row from pull_leads/pull_followups \u2014 you have the UUID. Pass it via \`leadId\`.
1421
2059
 
1422
- WHEN NOT TO USE: when you already have the lead_id and need the bundled deeper lens-scoped data \u2014 use leadbay_research_lead.
2060
+ Examples that SHOULD invoke this tool:
2061
+ - "Tell me everything about that lead I just picked."
2062
+ - "Deep dive on the lead at the top of my list."
2063
+ - "Is this one actually a fit \u2014 what does the AI think?"
2064
+
2065
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
2066
+ - "Look up Acme Corp for me."
2067
+ - "What do we know about Globex?"
2068
+ - "Show me today's leads."
2069
+
2070
+ ---
2071
+
2072
+ Tell me everything decision-relevant about a single lead, identified by its
2073
+ Leadbay UUID. Bundles the lens-scoped lead profile, the AI qualification
2074
+ answers (the agent's knowledge-base food), the structured web-research signals
2075
+ (with hot flags + sources), the two-tier contact set (\`enriched\` + \`org\`), the
2076
+ unified \`recent_activities\` timeline, the engagement counts, and a
2077
+ \`_meta.has_reachable_contact\` hint that drives NEXT STEPS. Order is
2078
+ deliberate: qualification first, then signals, then firmographics, then
2079
+ contacts, then recent activity.
2080
+
2081
+ Scoring has two layers: the basic \`score\` (firmographic, always present,
2082
+ already decent) and the AI qualification layer (\`ai_agent_lead_score\` +
2083
+ per-question answers + web_fetch signals). The AI layer is pre-populated for
2084
+ roughly the top 10 of each daily batch, and on-demand (via
2085
+ leadbay_bulk_qualify_leads) for anything below that. Combine both layers when
2086
+ judging a lead.
2087
+
2088
+ The companion tool **leadbay_research_lead_by_name_fuzzy** wraps this one for
2089
+ the case where the user names a company in prose without a UUID \u2014 it
2090
+ fuzzy-resolves the name against the active lens's wishlist, then delegates
2091
+ here. Both return the same shape; the fuzzy wrapper just adds
2092
+ \`_meta.resolved_from\` and \`_meta.match_candidates\` so you can offer
2093
+ disambiguation.
2094
+
2095
+ WHEN TO USE: when picking up a single lead from
2096
+ leadbay_pull_leads (or any list that exposed a leadId) to decide whether to
2097
+ act on it.
2098
+
2099
+ WHEN NOT TO USE: across many leads at once \u2014 that's
2100
+ leadbay_pull_leads' job. (This composite supersedes the lower-level
2101
+ leadbay_get_lead_profile in agent flow; the granular tool stays available for
2102
+ fine-grained access.)
2103
+
2104
+ **Concurrency note**: this is a composite that reads many sub-resources per
2105
+ call. Call it **sequentially** or in small batches (\u22643 parallel) when
2106
+ researching multiple leads. Firing 10+ in parallel can saturate the transport
2107
+ and produce misleading \`"Tool permission stream closed"\` errors that look like
2108
+ permission failures but are really backpressure. On a transient
2109
+ stream/timeout failure, retry the same lead once before moving on.
1423
2110
 
1424
2111
  ---
1425
2112
 
@@ -1427,7 +2114,7 @@ WHEN NOT TO USE: when you already have the lead_id and need the bundled deeper l
1427
2114
 
1428
2115
  Present as a single-record card, not a table. This tool gets invoked in two distinct user contexts \u2014 detect which and adapt the body density accordingly.
1429
2116
 
1430
- **MODE A \u2014 Discovery.** The user is evaluating whether to pursue this company as a target. Signals: "tell me about", "what do they do", "is this a fit", "research [company]", arrival via a click-through from \`leadbay_pull_leads\`, no prior outreach context in the conversation. Next step is usually qualify, deep-dive via \`leadbay_research_lead\`, or decide whether to start outreach.
2117
+ **MODE A \u2014 Discovery.** The user is evaluating whether to pursue this company as a target. Signals: "tell me about", "what do they do", "is this a fit", "research [company]", arrival via a click-through from \`leadbay_pull_leads\`, no prior outreach context in the conversation. Next step is usually qualify, deep-dive via \`leadbay_research_lead_by_id\`, or decide whether to start outreach.
1431
2118
 
1432
2119
  **MODE B \u2014 Contact preparation.** The user is about to call or email someone at this company and needs the talking points. Signals: "I'm calling them", "draft an email", "before my call", "outreach prep", "what should I say", or the conversation has already touched on a specific contact. Next step is usually \`leadbay_prepare_outreach\`.
1433
2120
 
@@ -1502,49 +2189,278 @@ When the response carries \`social_urls\` (the post-fix multi-platform URL block
1502
2189
 
1503
2190
  ## NEXT STEPS \u2014 after the research card
1504
2191
 
1505
- Offer 2\u20133 follow-ups that match the detected mode. Always offer a cross-mode pivot at the end so the user can redirect if you guessed wrong.
2192
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
2193
+
2194
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
2195
+
2196
+ \`\`\`
2197
+ ask_user_input_v0({
2198
+ questions: [{
2199
+ question: "What next?",
2200
+ type: "single_select",
2201
+ options: [
2202
+ "<Suggest column from row 1>",
2203
+ "<Suggest column from row 2>",
2204
+ "<Suggest column from row 3>"
2205
+ ]
2206
+ }]
2207
+ })
2208
+ \`\`\`
2209
+
2210
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
2211
+
2212
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
2213
+
2214
+ ---
2215
+
2216
+
2217
+
2218
+ Offer 2\u20133 follow-ups that match what the lead's response actually contains.
1506
2219
 
1507
- ### MODE A (Discovery)
2220
+ **Primary branching signal**: \`contacts.reachable[]\` vs \`contacts.candidates[]\`.
2221
+ - \`contacts.reachable[]\` = people with an email or phone right now. Message them directly.
2222
+ - \`contacts.candidates[]\` = people identified at this company (LinkedIn-only), \`enrichment_done: false\`. Cannot be messaged without first calling \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach({enrich:true})\`).
2223
+ - \`_meta.has_reachable_contact\` is the boolean shortcut \u2014 true iff \`reachable.length > 0\` or the recommended_contact has a channel.
1508
2224
 
1509
- | Observation | Suggest | Calls |
1510
- |--------------------------------------------------------|----------------------------------------------------|----------------------------------------------------|
1511
- | \`qualification[]\` is empty | "Run AI qualification on this lead" | leadbay_bulk_qualify_leads([leadId]) |
1512
- | \u22651 hot recent item in \u{1F4C8} business signals | "Prepare outreach referencing [signal headline]" | leadbay_prepare_outreach(leadId) |
1513
- | \`contacts_count > len(contacts)\` shown | "Pull the full contact list (N more)" | leadbay_get_contacts(leadId) |
1514
- | \`web_insights_fetched_at\` > 30 days | "Re-run the web research \u2014 this is stale" | leadbay_research_company(leadId) \u2014 refresh |
1515
- | User wants the deeper lens-scoped bundle | "Pull the full lead profile (research_lead)" | leadbay_research_lead(leadId) |
1516
- | User is exploring multiple companies | "Back to the lead list" | leadbay_pull_leads |
1517
- | \`qualification[]\` non-empty | "Expand the AI qualification answers" | (render qualification[] as a sub-card) |
2225
+ Always offer a cross-mode pivot at the end so the user can redirect if you guessed wrong.
1518
2226
 
1519
- End MODE A with the pivot offer: \`"Want the contact-prep view for [recommended contact name]?"\`
2227
+ ### MODE A \u2014 Nobody reachable yet (\`contacts.reachable\` is empty)
1520
2228
 
1521
- ### MODE B (Contact preparation)
2229
+ | Observation | Suggest | Calls |
2230
+ |--------------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------|
2231
+ | \`contacts.candidates[]\` non-empty | "Enrich N candidate contacts to acquire emails / phones" | leadbay_enrich_titles({ leadIds: [leadId] }) |
2232
+ | User wants outreach now anyway | "Enrich + draft outreach in one shot" | leadbay_prepare_outreach({ leadId, enrich: true }) |
2233
+ | \`qualification[]\` is empty | "Run AI qualification on this lead first" | leadbay_bulk_qualify_leads([leadId]) |
2234
+ | \`web_insights_fetched_at\` older than 30 days | "Refresh the web research \u2014 this is stale" | leadbay_research_lead_by_id({ leadId }) \u2014 re-runs the fetch |
2235
+ | User is exploring multiple companies | "Back to the lead list" | leadbay_pull_leads |
1522
2236
 
1523
- | Observation | Suggest | Calls |
1524
- |--------------------------------------------------------|----------------------------------------------------|----------------------------------------------------|
1525
- | \`phone_numbers[]\` non-empty | "Show full call notes + a 60-second opener" | leadbay_prepare_outreach(leadId) |
1526
- | Recommended contact has an email | "Draft the outreach email" | leadbay_prepare_outreach(leadId) |
1527
- | Neither phone nor email for recommended contact | "Order contact enrichment first" | leadbay_prepare_outreach(leadId, enrich:true) or leadbay_enrich_titles |
1528
- | After the user reports a touchpoint | "Log the call/email outcome" | leadbay_report_outreach |
1529
- | Adding pre-call context | "Add a note to this lead" | leadbay_add_note |
2237
+ End MODE A with the pivot offer: \`"Want the strategic overview before
2238
+ enriching? (already shown above)"\`
1530
2239
 
1531
- End MODE B with the pivot offer: \`"Want the full strategic overview instead?"\`
2240
+ ### MODE B \u2014 At least one reachable contact (\`contacts.reachable[]\` non-empty OR recommended_contact has channels)
2241
+
2242
+ | Observation | Suggest | Calls |
2243
+ |--------------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------|
2244
+ | Recommended contact has an email | "Draft the outreach email" | leadbay_prepare_outreach({ leadId }) |
2245
+ | \`firmographics.phone_numbers[]\` non-empty | "Show full call notes + a 60-second opener" | leadbay_prepare_outreach({ leadId }) |
2246
+ | \`recent_activities[]\` non-empty | "Log a follow-up touchpoint" | leadbay_report_outreach |
2247
+ | Adding pre-call context | "Add a note to this lead" | leadbay_add_note |
2248
+ | \`qualification[]\` non-empty | "Expand the AI qualification answers" | (render qualification[] as a sub-card) |
2249
+
2250
+ End MODE B with the pivot offer: \`"Want to qualify deeper before reaching
2251
+ out?"\`
2252
+
2253
+ ### Cross-mode rows (always available)
2254
+
2255
+ | Observation | Suggest | Calls |
2256
+ |--------------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------|
2257
+ | User is done with this lead | "Back to the inbox" | leadbay_pull_leads |
1532
2258
  `;
1533
- var leadbay_research_lead = `Tell me everything decision-relevant about a single lead. Bundles the lens-scoped lead profile, the AI qualification answers (the agent's knowledge-base food), the structured web-research signals (with hot flags + sources), the enriched contacts, and the recent notes/epilogue/prospecting activity in one call. Order is deliberate: qualification first, then signals, then firmographics, then contacts, then engagement.
2259
+ var leadbay_research_lead_by_name_fuzzy = `## WHEN TO USE
2260
+
2261
+ Trigger phrases: "look up <Company>", "research <Company>", "what do we know about <Company>", "find <Company> in my pipeline", "tell me about <Company>".
2262
+
2263
+ Do NOT use for: "user picked a row from a previous list (you have leadId)" \u2192 \`leadbay_research_lead_by_id\`; "draft outreach for <Contact>" \u2192 \`leadbay_prepare_outreach\`.
2264
+
2265
+ Prefer when: user names a company in prose without a Leadbay id \u2014 composite fuzzy-matches against the active lens's wishlist.
2266
+
2267
+ Examples that SHOULD invoke this tool:
2268
+ - "Look up Acme Corp for me."
2269
+ - "What do we know about Globex?"
2270
+ - "Find Initech in my pipeline."
2271
+
2272
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
2273
+ - "Tell me about that lead I just picked."
2274
+ - "Show me today's prospects."
2275
+ - "Draft outreach to Acme's CTO."
2276
+
2277
+ ---
2278
+
2279
+ Thin wrapper around **leadbay_research_lead_by_id**. Accepts a \`companyName\`,
2280
+ fuzzy-matches it against the top 50 of the active (or supplied) lens's
2281
+ wishlist, picks the highest-scoring substring hit, and delegates the actual
2282
+ research to \`leadbay_research_lead_by_id\`. Returns the **same payload shape**
2283
+ as \`_by_id\`, with two additions on \`_meta\`:
2284
+
2285
+ - \`_meta.resolved_from\`: \`"companyName"\` (so the agent knows the entry point).
2286
+ - \`_meta.resolved_query\`: the original \`companyName\` string.
2287
+ - \`_meta.match_candidates[]\`: up to 4 next-best substring matches as
2288
+ \`{leadId, name, score}\` \u2014 surface these when ambiguity is plausible so the
2289
+ user can redirect.
2290
+
2291
+ On zero matches, throws \`LEAD_NOT_FOUND\` with a \`nearest_names[]\` payload (the
2292
+ top 5 by score from the lens, regardless of substring) so the agent can offer
2293
+ "did you mean\u2026" disambiguation.
2294
+
2295
+ WHEN TO USE: when the user references a company by
2296
+ name and you don't yet have its \`lead_id\`. If \`_meta.match_candidates\` is
2297
+ non-empty, offer the next-best matches in NEXT STEPS so the user can correct
2298
+ a wrong fuzzy hit.
2299
+
2300
+ WHEN NOT TO USE: when you already have the UUID \u2014 use
2301
+ leadbay_research_lead_by_id directly. This wrapper costs one extra
2302
+ \`discoverLeads\` round-trip; skipping it when you can is cheaper.
2303
+
2304
+ ---
2305
+
2306
+ ## RENDERING \u2014 single-record research card, mode-adaptive
2307
+
2308
+ Present as a single-record card, not a table. This tool gets invoked in two distinct user contexts \u2014 detect which and adapt the body density accordingly.
2309
+
2310
+ **MODE A \u2014 Discovery.** The user is evaluating whether to pursue this company as a target. Signals: "tell me about", "what do they do", "is this a fit", "research [company]", arrival via a click-through from \`leadbay_pull_leads\`, no prior outreach context in the conversation. Next step is usually qualify, deep-dive via \`leadbay_research_lead_by_id\`, or decide whether to start outreach.
2311
+
2312
+ **MODE B \u2014 Contact preparation.** The user is about to call or email someone at this company and needs the talking points. Signals: "I'm calling them", "draft an email", "before my call", "outreach prep", "what should I say", or the conversation has already touched on a specific contact. Next step is usually \`leadbay_prepare_outreach\`.
2313
+
2314
+ Default to MODE A when uncertain. Always offer the cross-mode pivot at the end so the user can redirect if you guessed wrong.
2315
+
2316
+ ### Common structure (both modes)
2317
+
2318
+ - **Header** (H4 or H5): \`<10-segment score bar>\` \`[Company name](website)\`. Use the score-bar algorithm; the bar lives in a single inline-code span. Prefix \`https://\` to website if it's a bare hostname.
2319
+ - **Pill row** (immediately below the header): short location \xB7 compact size \xB7 social pill chips iterated over \`social_urls\` (each non-null platform becomes \`[<platform-label>](<url>)\`) \xB7 \`[website-domain](website)\` \xB7 \`\u260E phone\` when \`phone_numbers[]\` is non-empty (use the first number). All \` \xB7 \`-separated.
2320
+ - **Blurb**: render \`description\` (preferred) or \`short_description\` as a single blockquoted paragraph.
2321
+ - **Staleness line**: italic, \`"Researched <relative time>"\` from \`web_insights_fetched_at\`. Use \`"today"\` / \`"yesterday"\` / \`"N days ago"\` up to 30 days, then absolute date. Prefix with \`\u26A0\` if older than 30 days.
2322
+ - **Contacts table** (always at the bottom):
2323
+ \`\`\`
2324
+ | | Name | Title | LinkedIn |
2325
+ \`\`\`
2326
+ Markers in column 1:
2327
+ - \`\u2605\` \u2014 \`recommended_contact\` match.
2328
+ - \`\u{1F48E}\` \u2014 name fuzzy-matches a \`hot: true\` entry in \`web_insights\` key_people. (Use \`\u{1F48E}\`, not \`\u{1F525}\`, to avoid glyph collision with the follow-up status badge.)
2329
+ Sort \`\u2605\` first, then \`\u{1F48E}\`-only rows, then API order. Link the name via \`linkedin_page\` first; fall back to LinkedIn people-search with \`<First>+<Last>+<Company>\`. Append \`\xB0\` only when the fallback is in use AND \`social_presence.linkedin == false\`. Cap to 6 rows; if \`contacts_count > shown\`, end with \`"+N more \u2014 ask to see the full list"\`.
2330
+
2331
+ ### MODE A body (Discovery, fuller, scannable)
2332
+
2333
+ Render each non-empty \`web_insights\` section as H5 with the emoji + label intact. Section order: \`\u{1F3E2} company profile\` \u2192 \`\u{1F4C8} business signals\` \u2192 \`\u{1F4A1} prospecting clues\` \u2192 \`\u{1F9E9} strategic positioning\` \u2192 \`\u{1F50E} technologies & innovation\`. Inside each, bullet 3\u20135 items. Sort \`hot: true\` items first. **Bold** the description text of hot items; leave cold items plain. Render \`source\` as \`[source](url)\` at the end; include \`date\` when present. Omit empty sections. Skip \`\u{1F517} social links\` (already in the pill row) and \`\u{1F464} key people\` (already in the contacts table).
2334
+
2335
+ ### MODE B body (Contact preparation, tighter)
2336
+
2337
+ Render exactly two H5 sections:
2338
+
2339
+ ##### \u{1F3AF} Conversation hooks
2340
+
2341
+ Distill the 3 most recent / most hot signals from \`\u{1F4C8} business signals\` and \`\u{1F4A1} prospecting clues\` into one-sentence talking points in salesperson voice. Strip the academic framing. Cite the source inline.
2342
+
2343
+ ##### \u{1F464} About the person *(only when recommended_contact is non-empty)*
2344
+
2345
+ 2-line summary: their title + any context from \`web_insights\` key_people. If they appear in a hot signal ("X appointed CEO"), surface that prominently.
2346
+
2347
+ Skip \u{1F3E2} profile, \u{1F9E9} strategic positioning, \u{1F50E} technologies in MODE B \u2014 context the user doesn't need for the next 30 seconds.
2348
+
2349
+ If \`qualification[]\` is non-empty, append one collapsed line: \`"Qualification: N questions answered, avg boost X"\` and offer to expand in NEXT STEPS.
2350
+
2351
+ **Hide:** \`id\`, \`lead.id\`, \`contact.id\`, \`lead.location.pos\`, \`web_fetch_in_progress\`, \`enrichment_in_progress\`, \`recommended_contact_title\` (duplicates \`recommended_contact.job_title\`), empty arrays, fields whose value is the string \`"null"\`, \`contact.source\` (internal), insights whose \`source\` is empty.
2352
+
2353
+ **Legend (print once below the card):** \`\` \`\u25B0\` firmographic \xB7 \`\u2756\` AI booster \xB7 \`\u25B1\` unfilled \xB7 \u2605 recommended \xB7 \u{1F48E} hot in web_insights \xB7 \xB0 = no company LinkedIn (fallback link only) \`\`
2354
+
2355
+ ## Linking a contact's name
2356
+
2357
+ Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
2358
+
2359
+ When the response carries a real contact LinkedIn URL \u2014 \`contact.linkedin_page\` is a string that starts with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it) \u2014 link the contact's name to that URL.
2360
+
2361
+ Otherwise fall back to a LinkedIn people-search URL:
2362
+
2363
+ \`\`\`
2364
+ https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
2365
+ \`\`\`
2366
+
2367
+ URL-encode the params. Strip Inc / LLC / Corp / Ltd / GmbH suffixes from the company name. Append a trailing \` \xB0\` to the rendered name ONLY when the fallback is in use AND \`social_presence.linkedin == false\` (no company LinkedIn \u2192 search may not resolve). Never append \`\xB0\` when a real \`linkedin_page\` was used.
2368
+
2369
+ Never link a person's name to the company's LinkedIn page (and vice versa). The two surfaces are different \u2014 conflating them quietly degrades the workflow.
2370
+
2371
+ ## Linking the company
2372
+
2373
+ Use the lead's \`website\` as the company-name link target \u2014 prefix \`https://\` if the value is a bare hostname. (The MCP does NOT synthesize a Leadbay-app deep-link URL; the team has not standardized one. Linking to \`website\` is always real data.)
2374
+
2375
+ When the response carries \`social_urls\` (the post-fix multi-platform URL block on rich-lead responses), render every non-null platform as a pill chip in the company-info row. Iterate over \`social_urls\`'s keys \u2014 never hardcode a fixed list \u2014 and emit each as \`[<platform-label>](<url>)\`. Skip platforms whose URL is null.
2376
+
2377
+ \`social_presence\` carries booleans for the same 6 platforms (crunchbase, facebook, instagram, linkedin, tiktok, twitter) \u2014 useful when you only care that the company has a profile somewhere. Use it as the \xB0-flag signal in the contact people-search fallback (see linking/contact-linkedin).
2378
+
2379
+
2380
+
2381
+ ---
2382
+
2383
+ ## NEXT STEPS \u2014 after the research card
2384
+
2385
+ **RENDER NEXT STEPS via \`ask_user_input_v0\` when the host exposes it.**
2386
+
2387
+ The (Observation, Suggest, Calls) table below is the source of truth for which moves are valid. Pick the 2\u20134 most relevant rows based on what the response actually contains, then surface them as a \`single_select\` quick-select widget:
2388
+
2389
+ \`\`\`
2390
+ ask_user_input_v0({
2391
+ questions: [{
2392
+ question: "What next?",
2393
+ type: "single_select",
2394
+ options: [
2395
+ "<Suggest column from row 1>",
2396
+ "<Suggest column from row 2>",
2397
+ "<Suggest column from row 3>"
2398
+ ]
2399
+ }]
2400
+ })
2401
+ \`\`\`
2402
+
2403
+ When the user picks an option, you call the matching tool from the \`Calls\` column. Constraints carried over from the widget contract: 2\u20134 mutually-exclusive options per question, button-sized labels (\u22646 words), max 3 questions per call.
2404
+
2405
+ **Fallback prose mode** \u2014 when the host doesn't expose \`ask_user_input_v0\` (or it returned an error): surface the same 2\u20133 picks as a short bulleted list of "Suggest" phrasings. The table itself stays internal; never recite the whole table to the user.
2406
+
2407
+ ---
2408
+
1534
2409
 
1535
- Scoring has two layers: the basic \`score\` (firmographic, always present, already decent) and the AI qualification layer (\`ai_agent_lead_score\` + per-question answers + web_fetch signals). The AI layer is pre-populated for roughly the top 10 of each daily batch, and on-demand (via leadbay_bulk_qualify_leads) for anything below that. Combine both layers when judging a lead.
1536
2410
 
1537
- WHEN TO USE: when picking up a single lead from leadbay_pull_leads to decide whether to act on it.
2411
+ Offer 2\u20133 follow-ups that match what the lead's response actually contains.
1538
2412
 
1539
- WHEN NOT TO USE: across many leads at once \u2014 that's leadbay_pull_leads' job. (This composite supersedes the lower-level leadbay_get_lead_profile in agent flow; the granular tool stays available for fine-grained access.)
2413
+ **Primary branching signal**: \`contacts.reachable[]\` vs \`contacts.candidates[]\`.
2414
+ - \`contacts.reachable[]\` = people with an email or phone right now. Message them directly.
2415
+ - \`contacts.candidates[]\` = people identified at this company (LinkedIn-only), \`enrichment_done: false\`. Cannot be messaged without first calling \`leadbay_enrich_titles\` (or \`leadbay_prepare_outreach({enrich:true})\`).
2416
+ - \`_meta.has_reachable_contact\` is the boolean shortcut \u2014 true iff \`reachable.length > 0\` or the recommended_contact has a channel.
1540
2417
 
1541
- **Concurrency note**: this is a composite that reads many sub-resources per call. Call it **sequentially** or in small batches (\u22643 parallel) when researching multiple leads. Firing 10+ in parallel can saturate the transport and produce misleading \`"Tool permission stream closed"\` errors that look like permission failures but are really backpressure. On a transient stream/timeout failure, retry the same lead once before moving on.
2418
+ Always offer a cross-mode pivot at the end so the user can redirect if you guessed wrong.
2419
+
2420
+ ### MODE A \u2014 Nobody reachable yet (\`contacts.reachable\` is empty)
2421
+
2422
+ | Observation | Suggest | Calls |
2423
+ |--------------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------|
2424
+ | \`contacts.candidates[]\` non-empty | "Enrich N candidate contacts to acquire emails / phones" | leadbay_enrich_titles({ leadIds: [leadId] }) |
2425
+ | User wants outreach now anyway | "Enrich + draft outreach in one shot" | leadbay_prepare_outreach({ leadId, enrich: true }) |
2426
+ | \`qualification[]\` is empty | "Run AI qualification on this lead first" | leadbay_bulk_qualify_leads([leadId]) |
2427
+ | \`web_insights_fetched_at\` older than 30 days | "Refresh the web research \u2014 this is stale" | leadbay_research_lead_by_id({ leadId }) \u2014 re-runs the fetch |
2428
+ | User is exploring multiple companies | "Back to the lead list" | leadbay_pull_leads |
2429
+
2430
+ End MODE A with the pivot offer: \`"Want the strategic overview before
2431
+ enriching? (already shown above)"\`
2432
+
2433
+ ### MODE B \u2014 At least one reachable contact (\`contacts.reachable[]\` non-empty OR recommended_contact has channels)
2434
+
2435
+ | Observation | Suggest | Calls |
2436
+ |--------------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------|
2437
+ | Recommended contact has an email | "Draft the outreach email" | leadbay_prepare_outreach({ leadId }) |
2438
+ | \`firmographics.phone_numbers[]\` non-empty | "Show full call notes + a 60-second opener" | leadbay_prepare_outreach({ leadId }) |
2439
+ | \`recent_activities[]\` non-empty | "Log a follow-up touchpoint" | leadbay_report_outreach |
2440
+ | Adding pre-call context | "Add a note to this lead" | leadbay_add_note |
2441
+ | \`qualification[]\` non-empty | "Expand the AI qualification answers" | (render qualification[] as a sub-card) |
2442
+
2443
+ End MODE B with the pivot offer: \`"Want to qualify deeper before reaching
2444
+ out?"\`
2445
+
2446
+ ### Cross-mode rows (always available)
2447
+
2448
+ | Observation | Suggest | Calls |
2449
+ |--------------------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------|
2450
+ | User is done with this lead | "Back to the inbox" | leadbay_pull_leads |
2451
+
2452
+
2453
+ When \`_meta.match_candidates\` is non-empty, prepend one extra NEXT STEPS row:
2454
+
2455
+ | Observation | Suggest | Calls |
2456
+ |--------------------------------------------------------|----------------------------------------------------------------------|------------------------------------------------------------------------|
2457
+ | \`_meta.match_candidates\` non-empty | "Did you mean **&lt;next-best.name&gt;**?" | leadbay_research_lead_by_id(leadId=&lt;next-best.leadId&gt;) |
1542
2458
  `;
1543
2459
  var leadbay_resolve_import_rows = `Resolve messy CSV-shaped lead rows against Leadbay before file import. The tool sends each row's available identity signals to \`POST /leads/resolve\`, returns matched lead IDs or ambiguous candidate IDs, and produces \`records_for_import\` plus a SAFE identity-only \`mappings_for_import\` starting point for leadbay_import_leads / leadbay_import_and_qualify. This tool deliberately does not try to understand every CSV dialect; the agent should inspect the file, derive clean helper columns when useful, pass explicit \`identity_mappings\`, and build the final CRM mapping from \`mapping_guidance\`.
1544
2460
 
1545
2461
  WHEN TO USE: before importing user-supplied files when domains, names, CRM IDs, registry numbers, or Leadbay IDs may be inconsistently formatted; when the agent needs to pre-resolve messy rows, inspect ambiguous candidates, or prepare LEADBAY_ID values for the import composites. For contact-only files, first derive company website/domain from business contact emails where possible, while ignoring consumer mailbox domains. Deterministic matches get a LEADBAY_ID column inserted so the standard import commits immediately. Ambiguous rows are deliberately left without LEADBAY_ID; inspect candidates and choose one only when the evidence is good. Rows with websites but no match can still be imported; Leadbay may crawl and match them later, and leadbay_import_status can surface late matches.
1546
2462
 
1547
- WHEN NOT TO USE: for prospect discovery from scratch (use leadbay_pull_leads); for one known company profile (use leadbay_research_company / leadbay_research_lead); or when the file already has clean, final LEADBAY_ID/CRM_ID/SIREN mappings and no row-level identity disambiguation is needed.
2463
+ WHEN NOT TO USE: for prospect discovery from scratch (use leadbay_pull_leads); for one known company profile (use leadbay_research_lead_by_name_fuzzy / leadbay_research_lead_by_id); or when the file already has clean, final LEADBAY_ID/CRM_ID/SIREN mappings and no row-level identity disambiguation is needed.
1548
2464
 
1549
2465
  ---
1550
2466
 
@@ -2380,30 +3296,77 @@ var listSectors = {
2380
3296
  idempotentHint: true,
2381
3297
  openWorldHint: true
2382
3298
  },
2383
- description: leadbay_list_sectors,
2384
- inputSchema: {
3299
+ description: leadbay_list_sectors,
3300
+ inputSchema: {
3301
+ type: "object",
3302
+ properties: {
3303
+ lang: { type: "string", description: "BCP-47 language tag (default: en)" },
3304
+ includeInvisible: {
3305
+ type: "boolean",
3306
+ description: "Include sectors hidden from the UI (default false; ~91k items if true)"
3307
+ }
3308
+ },
3309
+ additionalProperties: false
3310
+ },
3311
+ execute: async (client, params) => {
3312
+ let lang = params.lang;
3313
+ if (!lang) {
3314
+ try {
3315
+ const me = await client.resolveMe();
3316
+ lang = me.language ?? "en";
3317
+ } catch {
3318
+ lang = "en";
3319
+ }
3320
+ }
3321
+ const includeInvisible = params.includeInvisible ? "true" : "false";
3322
+ const path = `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=${includeInvisible}`;
3323
+ return await client.request("GET", path);
3324
+ }
3325
+ };
3326
+
3327
+ // ../core/dist/tools/list-locations.js
3328
+ var listLocations = {
3329
+ name: "leadbay_list_locations",
3330
+ annotations: {
3331
+ title: "Search the geo / admin-area taxonomy",
3332
+ readOnlyHint: true,
3333
+ destructiveHint: false,
3334
+ idempotentHint: true,
3335
+ openWorldHint: true
3336
+ },
3337
+ description: leadbay_list_locations,
3338
+ inputSchema: {
3339
+ type: "object",
3340
+ properties: {
3341
+ q: {
3342
+ type: "string",
3343
+ description: "Free-text city / region name (e.g. 'Berlin', 'NYC', 'S\xE3o Paulo'). Returns top-10 prefix matches sorted by relevance, each with an admin_area id usable in FilterCriterion.location_ids."
3344
+ }
3345
+ },
3346
+ required: ["q"],
3347
+ additionalProperties: false
3348
+ },
3349
+ outputSchema: {
2385
3350
  type: "object",
2386
3351
  properties: {
2387
- lang: { type: "string", description: "BCP-47 language tag (default: en)" },
2388
- includeInvisible: {
2389
- type: "boolean",
2390
- description: "Include sectors hidden from the UI (default false; ~91k items if true)"
3352
+ results: {
3353
+ type: "array",
3354
+ description: "Matches sorted by relevance. Each entry: {id, country, level, name, parent_ids}. `level` is admin depth (5=region, 6=county, 7=township-area, 8=city/town).",
3355
+ items: { type: "object" }
3356
+ },
3357
+ parents: {
3358
+ type: "array",
3359
+ description: "Parent admin areas referenced by `results[].parent_ids`, returned for breadcrumb / hover-disambiguation rendering.",
3360
+ items: { type: "object" }
2391
3361
  }
2392
3362
  },
2393
- additionalProperties: false
3363
+ required: ["results", "parents"]
2394
3364
  },
2395
3365
  execute: async (client, params) => {
2396
- let lang = params.lang;
2397
- if (!lang) {
2398
- try {
2399
- const me = await client.resolveMe();
2400
- lang = me.language ?? "en";
2401
- } catch {
2402
- lang = "en";
2403
- }
2404
- }
2405
- const includeInvisible = params.includeInvisible ? "true" : "false";
2406
- const path = `/sectors/all?lang=${encodeURIComponent(lang)}&includeInvisible=${includeInvisible}`;
3366
+ const q = (params.q ?? "").trim();
3367
+ if (!q)
3368
+ return { results: [], parents: [] };
3369
+ const path = `/geo/search?q=${encodeURIComponent(q)}`;
2407
3370
  return await client.request("GET", path);
2408
3371
  }
2409
3372
  };
@@ -2587,7 +3550,7 @@ var getWebFetch = {
2587
3550
  },
2588
3551
  outputSchema: {
2589
3552
  type: "object",
2590
- description: "Raw LeadWebFetchPayload as returned by /leads/{id}/web_fetch. Permissive shape \u2014 backend dict structure is documented in detail by leadbay_research_lead which reshapes it.",
3553
+ description: "Raw LeadWebFetchPayload as returned by /leads/{id}/web_fetch. Permissive shape \u2014 backend dict structure is documented in detail by leadbay_research_lead_by_id which reshapes it.",
2591
3554
  properties: {
2592
3555
  content: {
2593
3556
  description: "Backend dict content (object or string), or null when no fetch yet."
@@ -4188,6 +5151,60 @@ function coerceCsvValue(v) {
4188
5151
  return "";
4189
5152
  }
4190
5153
 
5154
+ // ../core/dist/tools/create-topup-link.js
5155
+ var createTopupLink = {
5156
+ name: "leadbay_create_topup_link",
5157
+ annotations: {
5158
+ title: "Generate Stripe checkout URL for AI-credits top-up",
5159
+ readOnlyHint: true,
5160
+ destructiveHint: false,
5161
+ idempotentHint: false,
5162
+ openWorldHint: true
5163
+ },
5164
+ description: leadbay_create_topup_link,
5165
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
5166
+ outputSchema: {
5167
+ type: "object",
5168
+ properties: {
5169
+ url: {
5170
+ type: "string",
5171
+ description: "Stripe-hosted checkout URL. Surface as a clickable link; the user completes payment in their browser. Top-up clears the throttle immediately after Stripe webhook lands."
5172
+ }
5173
+ },
5174
+ required: ["url"]
5175
+ },
5176
+ execute: async (client) => {
5177
+ return await client.request("POST", "/stripe/topup_checkout", {});
5178
+ }
5179
+ };
5180
+
5181
+ // ../core/dist/tools/open-billing-portal.js
5182
+ var openBillingPortal = {
5183
+ name: "leadbay_open_billing_portal",
5184
+ annotations: {
5185
+ title: "Generate Stripe customer-portal URL for subscription management",
5186
+ readOnlyHint: true,
5187
+ destructiveHint: false,
5188
+ idempotentHint: false,
5189
+ openWorldHint: true
5190
+ },
5191
+ description: leadbay_open_billing_portal,
5192
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
5193
+ outputSchema: {
5194
+ type: "object",
5195
+ properties: {
5196
+ url: {
5197
+ type: "string",
5198
+ description: "Stripe customer-portal URL. Surface as a clickable link; the user manages subscription / payment methods / invoices in their browser."
5199
+ }
5200
+ },
5201
+ required: ["url"]
5202
+ },
5203
+ execute: async (client) => {
5204
+ return await client.request("GET", "/stripe/portal");
5205
+ }
5206
+ };
5207
+
4191
5208
  // ../core/dist/tools/select-leads.js
4192
5209
  var selectLeads = {
4193
5210
  name: "leadbay_select_leads",
@@ -4994,87 +6011,63 @@ var createCustomField = {
4994
6011
  }
4995
6012
  };
4996
6013
 
4997
- // ../core/dist/composite/research-company.js
4998
- var researchCompany = {
4999
- name: "leadbay_research_company",
6014
+ // ../core/dist/tools/like-lead.js
6015
+ var likeLead = {
6016
+ name: "leadbay_like_lead",
5000
6017
  annotations: {
5001
- title: "Research a company by name",
5002
- readOnlyHint: true,
6018
+ title: "Like a lead",
6019
+ readOnlyHint: false,
5003
6020
  destructiveHint: false,
5004
6021
  idempotentHint: true,
5005
6022
  openWorldHint: true
5006
6023
  },
5007
- description: leadbay_research_company,
6024
+ description: leadbay_like_lead,
6025
+ optional: true,
6026
+ write: true,
5008
6027
  inputSchema: {
5009
6028
  type: "object",
5010
6029
  properties: {
5011
- companyName: {
5012
- type: "string",
5013
- description: "Company name to research (one of companyName or leadId required). Matches the top-scoring lead with this name."
5014
- },
5015
- leadId: {
6030
+ lead_id: {
5016
6031
  type: "string",
5017
- description: "Lead UUID if already known (one of companyName or leadId required). Takes precedence over companyName."
6032
+ description: "UUID of the lead to like."
5018
6033
  }
5019
6034
  },
6035
+ required: ["lead_id"],
5020
6036
  additionalProperties: false
5021
6037
  },
5022
- outputSchema: {
6038
+ execute: async (client, params) => {
6039
+ await client.requestVoid("POST", `/leads/${params.lead_id}/like`);
6040
+ return { applied: true, lead_id: params.lead_id, action: "liked" };
6041
+ }
6042
+ };
6043
+
6044
+ // ../core/dist/tools/dislike-lead.js
6045
+ var dislikeLead = {
6046
+ name: "leadbay_dislike_lead",
6047
+ annotations: {
6048
+ title: "Dislike a lead",
6049
+ readOnlyHint: false,
6050
+ destructiveHint: true,
6051
+ idempotentHint: true,
6052
+ openWorldHint: true
6053
+ },
6054
+ description: leadbay_dislike_lead,
6055
+ optional: true,
6056
+ write: true,
6057
+ inputSchema: {
5023
6058
  type: "object",
5024
6059
  properties: {
5025
- lead: {
5026
- type: "object",
5027
- description: "Lead profile basics (id, name, score, ai_agent_lead_score, location, description, short_description, size, website, logo, ai_summary, tags, phone_numbers, keywords, contacts_count, recommended_contact_title, recommended_contact, web_fetch_in_progress)."
5028
- },
5029
- qualification: {
5030
- type: ["array", "null"],
5031
- description: "Per-question AI qualification answers ({question, score, response, computed_at, outdated_at}), or null if none.",
5032
- items: { type: "object" }
5033
- },
5034
- contacts: {
5035
- type: "array",
5036
- description: "Merged org + paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
5037
- items: { type: "object" }
5038
- },
5039
- web_insights: {
5040
- description: "Latest /web_fetch content (string) or null when no fetch is available."
5041
- },
5042
- web_insights_fetched_at: {
5043
- description: "ISO timestamp of the latest /web_fetch (string) or null."
5044
- },
5045
- recent_activities: {
5046
- type: "array",
5047
- description: "Recent activities for this lead (top 20). Each is the activity payload as returned by /leads/{id}/activities.",
5048
- items: { type: "object" }
6060
+ lead_id: {
6061
+ type: "string",
6062
+ description: "UUID of the lead to dislike."
5049
6063
  }
5050
6064
  },
5051
- required: ["lead", "contacts", "recent_activities"]
6065
+ required: ["lead_id"],
6066
+ additionalProperties: false
5052
6067
  },
5053
- execute: async (client, params, ctx) => {
5054
- if (!params.leadId && !params.companyName) {
5055
- throw client.makeError("INVALID_PARAMS", "Pass either leadId or companyName", "Call leadbay_pull_leads first to surface candidate leads with their IDs, then call this with leadId.");
5056
- }
5057
- let leadId = params.leadId;
5058
- if (!leadId && params.companyName) {
5059
- const results = await discoverLeads.execute(client, { count: 50, page: 0 }, ctx);
5060
- const needle = params.companyName.toLowerCase();
5061
- const match = results.leads.find((l) => l.name.toLowerCase().includes(needle));
5062
- if (!match) {
5063
- throw client.makeError("LEAD_NOT_FOUND", `No lead matching "${params.companyName}" in the current lens`, "Call leadbay_pull_leads (optionally with a broader lensId) to see what's available, then call this with leadId.");
5064
- }
5065
- leadId = match.id;
5066
- }
5067
- const [profile, activities] = await Promise.allSettled([
5068
- getLeadProfile.execute(client, { leadId }, ctx),
5069
- getLeadActivities.execute(client, { leadId, count: 20 }, ctx)
5070
- ]);
5071
- if (profile.status === "rejected") {
5072
- throw profile.reason;
5073
- }
5074
- return {
5075
- ...profile.value,
5076
- recent_activities: activities.status === "fulfilled" ? activities.value.activities : []
5077
- };
6068
+ execute: async (client, params) => {
6069
+ await client.requestVoid("POST", `/leads/${params.lead_id}/dislike`);
6070
+ return { applied: true, lead_id: params.lead_id, action: "disliked" };
5078
6071
  }
5079
6072
  };
5080
6073
 
@@ -5441,6 +6434,119 @@ var pullLeads = {
5441
6434
  }
5442
6435
  };
5443
6436
 
6437
+ // ../core/dist/composite/_geo-helpers.js
6438
+ var looksLikeId = (s) => /^\d+$/.test(s);
6439
+ var CITY_ALIASES = {
6440
+ nyc: "City of New York",
6441
+ "ny city": "City of New York",
6442
+ "new york": "City of New York",
6443
+ // user typing "New York" almost always means the city
6444
+ "new york city": "City of New York",
6445
+ manhattan: "City of New York",
6446
+ "the big apple": "City of New York",
6447
+ la: "Los Angeles",
6448
+ "l.a.": "Los Angeles",
6449
+ sf: "San Francisco",
6450
+ "s.f.": "San Francisco",
6451
+ "san fran": "San Francisco",
6452
+ dc: "Washington",
6453
+ "d.c.": "Washington",
6454
+ "washington d.c.": "Washington",
6455
+ "washington dc": "Washington",
6456
+ philly: "Philadelphia",
6457
+ vegas: "Las Vegas",
6458
+ nola: "New Orleans"
6459
+ };
6460
+ function expandAlias(text) {
6461
+ const key = text.trim().toLowerCase();
6462
+ return CITY_ALIASES[key] ?? text;
6463
+ }
6464
+ function scoreMatch(text, match) {
6465
+ const t = text.trim().toLowerCase();
6466
+ const n = match.name.trim().toLowerCase();
6467
+ if (n === t)
6468
+ return 1;
6469
+ if (n.startsWith(t))
6470
+ return 0.6 + 0.2 * (t.length / n.length);
6471
+ const wantTokens = new Set(t.split(/\s+/));
6472
+ const haveTokens = new Set(n.split(/\s+/));
6473
+ let intersect = 0;
6474
+ for (const w of wantTokens)
6475
+ if (haveTokens.has(w))
6476
+ intersect += 1;
6477
+ const union = (/* @__PURE__ */ new Set([...wantTokens, ...haveTokens])).size;
6478
+ return union > 0 ? intersect / union : 0;
6479
+ }
6480
+ async function resolveLocations(client, texts) {
6481
+ const direct = texts.filter((t) => looksLikeId(t));
6482
+ const free = texts.filter((t) => t && !looksLikeId(t));
6483
+ if (free.length === 0)
6484
+ return { resolved: direct, ambiguities: [] };
6485
+ const resolved = [...direct];
6486
+ const ambiguities = [];
6487
+ for (const originalText of free) {
6488
+ const text = expandAlias(originalText);
6489
+ const path = `/geo/search?q=${encodeURIComponent(text)}`;
6490
+ let response;
6491
+ try {
6492
+ response = await client.request("GET", path);
6493
+ } catch {
6494
+ ambiguities.push({ location_text: originalText, matches: [] });
6495
+ continue;
6496
+ }
6497
+ const results = response.results ?? [];
6498
+ if (results.length === 0) {
6499
+ ambiguities.push({ location_text: originalText, matches: [] });
6500
+ continue;
6501
+ }
6502
+ const levelPreference = (level) => {
6503
+ switch (level) {
6504
+ case 5:
6505
+ return 4;
6506
+ // city proper
6507
+ case 8:
6508
+ return 3;
6509
+ // standard city/town
6510
+ case 6:
6511
+ return 2;
6512
+ // county
6513
+ case 7:
6514
+ return 1;
6515
+ // township-area
6516
+ case 4:
6517
+ return 0;
6518
+ // state/region
6519
+ default:
6520
+ return 0;
6521
+ }
6522
+ };
6523
+ const ranked = results.map((r) => ({
6524
+ id: r.id,
6525
+ name: r.name,
6526
+ country: r.country,
6527
+ level: r.level,
6528
+ score: scoreMatch(text, r)
6529
+ })).sort((a, b) => {
6530
+ if (b.score !== a.score)
6531
+ return b.score - a.score;
6532
+ return levelPreference(b.level) - levelPreference(a.level);
6533
+ });
6534
+ const top = ranked[0];
6535
+ const runnerUp = ranked[1];
6536
+ const isConfident = top.score >= 0.95 || // exact-name match
6537
+ ranked.length === 1 || top.score >= 0.66 && (!runnerUp || top.score - runnerUp.score >= 0.34);
6538
+ if (isConfident) {
6539
+ resolved.push(top.id);
6540
+ } else {
6541
+ ambiguities.push({
6542
+ location_text: originalText,
6543
+ matches: ranked.slice(0, 5)
6544
+ });
6545
+ }
6546
+ }
6547
+ return { resolved, ambiguities };
6548
+ }
6549
+
5444
6550
  // ../core/dist/composite/pull-followups.js
5445
6551
  function normalizeLinkedinPage4(v) {
5446
6552
  if (v == null)
@@ -5460,6 +6566,23 @@ function augmentContact(c) {
5460
6566
  linkedin_page: normalizeLinkedinPage4(c.linkedin_page ?? null)
5461
6567
  };
5462
6568
  }
6569
+ function mergeLocationIds(filter, ids) {
6570
+ const criteria = filter?.criteria ? [...filter.criteria] : [];
6571
+ const idx = criteria.findIndex((c) => c?.type === "location_ids" && c?.is_excluded === false);
6572
+ if (idx >= 0) {
6573
+ const cur = criteria[idx];
6574
+ const existing = Array.isArray(cur.locations) ? cur.locations : [];
6575
+ const merged = Array.from(/* @__PURE__ */ new Set([...existing, ...ids]));
6576
+ criteria[idx] = { ...cur, locations: merged };
6577
+ } else {
6578
+ criteria.push({
6579
+ type: "location_ids",
6580
+ is_excluded: false,
6581
+ locations: ids
6582
+ });
6583
+ }
6584
+ return { criteria };
6585
+ }
5463
6586
  var pullFollowups = {
5464
6587
  name: "leadbay_pull_followups",
5465
6588
  annotations: {
@@ -5503,6 +6626,14 @@ var pullFollowups = {
5503
6626
  items: { type: "object" }
5504
6627
  }
5505
6628
  }
6629
+ },
6630
+ city: {
6631
+ type: "string",
6632
+ description: "Free-text city / region (e.g. 'Berlin', 'NYC', 'S\xE3o Paulo'). The composite resolves it to an admin_area id via GET /geo/search and merges it into the active Monitor filter as a `location_ids` FilterCriterion. Ambiguous matches surface as `status: 'ambiguous_locations'` with `location_ambiguities[]` \u2014 the agent picks an id and re-calls via `city_id`."
6633
+ },
6634
+ city_id: {
6635
+ type: "string",
6636
+ description: "Pre-resolved admin_area id (numeric string). Use when the user / agent has already picked one of the ambiguity candidates. Bypasses the resolver."
5506
6637
  }
5507
6638
  },
5508
6639
  additionalProperties: false
@@ -5527,6 +6658,15 @@ var pullFollowups = {
5527
6658
  type: "number",
5528
6659
  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."
5529
6660
  },
6661
+ status: {
6662
+ type: "string",
6663
+ description: "`ambiguous_locations` when a passed `city` matched multiple admin_areas; the agent picks an id from `location_ambiguities` and re-calls with `city_id`. Absent on the happy path."
6664
+ },
6665
+ location_ambiguities: {
6666
+ type: "array",
6667
+ description: "Per ambiguous city: {location_text, matches:[{id, name, country, level, score}]}. Only present when `status === 'ambiguous_locations'`.",
6668
+ items: { type: "object" }
6669
+ },
5530
6670
  _meta: {
5531
6671
  type: "object",
5532
6672
  description: "Operator context: region + last-call latency.",
@@ -5544,9 +6684,35 @@ var pullFollowups = {
5544
6684
  const liked = params.liked ?? false;
5545
6685
  const page = params.page ?? 0;
5546
6686
  const count = Math.min(params.count ?? 20, 200);
5547
- if (params.set_filter) {
6687
+ let effectiveSetFilter = params.set_filter;
6688
+ const geoTexts = [];
6689
+ if (params.city)
6690
+ geoTexts.push(params.city);
6691
+ if (params.city_id)
6692
+ geoTexts.push(params.city_id);
6693
+ if (geoTexts.length > 0) {
6694
+ const { resolved, ambiguities } = await resolveLocations(client, geoTexts);
6695
+ if (ambiguities.length > 0) {
6696
+ return {
6697
+ status: "ambiguous_locations",
6698
+ location_ambiguities: ambiguities,
6699
+ leads: [],
6700
+ active_filters: null,
6701
+ pagination: null,
6702
+ total_excluded_by_pushback: 0,
6703
+ _meta: {
6704
+ region: client.region,
6705
+ latency_ms: client.lastMeta?.latency_ms ?? null
6706
+ }
6707
+ };
6708
+ }
6709
+ if (resolved.length > 0) {
6710
+ effectiveSetFilter = mergeLocationIds(effectiveSetFilter, resolved);
6711
+ }
6712
+ }
6713
+ if (effectiveSetFilter) {
5548
6714
  try {
5549
- await client.requestVoid("POST", "/monitor/filter", params.set_filter);
6715
+ await client.requestVoid("POST", "/monitor/filter", effectiveSetFilter);
5550
6716
  } catch (err) {
5551
6717
  ctx?.logger?.warn?.(`pull_followups: POST /monitor/filter failed: ${err?.message ?? err?.code ?? err}`);
5552
6718
  }
@@ -5606,7 +6772,28 @@ var pullFollowups = {
5606
6772
  }
5607
6773
  };
5608
6774
 
5609
- // ../core/dist/composite/research-lead.js
6775
+ // ../core/dist/composite/followups-map.js
6776
+ var followupsMap = {
6777
+ name: "leadbay_followups_map",
6778
+ // NO ui binding — see the file-level comment above. The agent
6779
+ // routes into host-native widgets via the description's RENDER
6780
+ // directives instead of an auto-rendered MCP Apps widget.
6781
+ annotations: {
6782
+ title: "Plot follow-up leads on a map (travel / in-person intent)",
6783
+ readOnlyHint: true,
6784
+ destructiveHint: false,
6785
+ idempotentHint: true,
6786
+ openWorldHint: true
6787
+ },
6788
+ description: leadbay_followups_map,
6789
+ // Delegate everything else verbatim — same params, same output, same
6790
+ // city resolver / set_filter / pushback exclusion behavior.
6791
+ inputSchema: pullFollowups.inputSchema,
6792
+ outputSchema: pullFollowups.outputSchema,
6793
+ execute: pullFollowups.execute
6794
+ };
6795
+
6796
+ // ../core/dist/composite/research-lead-by-id.js
5610
6797
  function normalizeLinkedinPage5(v) {
5611
6798
  if (v == null)
5612
6799
  return null;
@@ -5661,18 +6848,33 @@ ${firm.short_description}`);
5661
6848
  }
5662
6849
  }
5663
6850
  const contacts = shape.contacts ?? {};
5664
- const enriched = Array.isArray(contacts.enriched) ? contacts.enriched : [];
5665
- if (enriched.length > 0) {
6851
+ const reachable = Array.isArray(contacts.reachable) ? contacts.reachable : [];
6852
+ if (reachable.length > 0) {
5666
6853
  out.push(`
5667
- ## Contacts (enriched)`);
5668
- for (const c of enriched.slice(0, 10)) {
6854
+ ## Contacts \u2014 reachable now`);
6855
+ for (const c of reachable.slice(0, 10)) {
5669
6856
  const fn = c.first_name ?? "";
5670
6857
  const ln = c.last_name ?? "";
5671
6858
  const title = c.job_title ?? "\u2014";
5672
- const email = c.email ?? "no email";
5673
- out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${email}`);
6859
+ const channel = c.email ?? c.phone_number ?? "\u2014";
6860
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${channel}`);
5674
6861
  }
5675
6862
  }
6863
+ const candidates = Array.isArray(contacts.candidates) ? contacts.candidates : [];
6864
+ if (candidates.length > 0) {
6865
+ out.push(`
6866
+ ## Contacts \u2014 candidates (need enrichment)`);
6867
+ for (const c of candidates.slice(0, 10)) {
6868
+ const fn = c.first_name ?? "";
6869
+ const ln = c.last_name ?? "";
6870
+ const title = c.job_title ?? "\u2014";
6871
+ const li = c.linkedin_page ? `LinkedIn` : "no LinkedIn";
6872
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${li}`);
6873
+ }
6874
+ if (candidates.length > 10)
6875
+ out.push(`- _${candidates.length - 10} more \u2026_`);
6876
+ }
6877
+ const recentActivities = Array.isArray(shape.recent_activities) ? shape.recent_activities : [];
5676
6878
  const engagement = shape.engagement ?? {};
5677
6879
  const counts = [
5678
6880
  ["notes", engagement.notes_count],
@@ -5680,7 +6882,7 @@ ${firm.short_description}`);
5680
6882
  ["prospecting", engagement.prospecting_actions_count]
5681
6883
  ];
5682
6884
  const activeCounts = counts.filter(([, v]) => typeof v === "number" && v > 0);
5683
- if (activeCounts.length > 0 || engagement.liked || engagement.disliked) {
6885
+ if (activeCounts.length > 0 || engagement.liked || engagement.disliked || recentActivities.length > 0) {
5684
6886
  out.push(`
5685
6887
  ## Engagement`);
5686
6888
  if (engagement.liked)
@@ -5690,6 +6892,15 @@ ${firm.short_description}`);
5690
6892
  for (const [k, v] of activeCounts) {
5691
6893
  out.push(`- ${k}: ${v}`);
5692
6894
  }
6895
+ if (recentActivities.length > 0) {
6896
+ out.push(`
6897
+ ### Recent activity`);
6898
+ for (const a of recentActivities.slice(0, 10)) {
6899
+ const type = a.type ?? "?";
6900
+ const date = a.date ?? "";
6901
+ out.push(`- ${type} \xB7 ${date}`);
6902
+ }
6903
+ }
5693
6904
  }
5694
6905
  if (shape.truncated) {
5695
6906
  out.push(`
@@ -5729,16 +6940,23 @@ function reshapeWebFetchContent(content) {
5729
6940
  });
5730
6941
  return sections;
5731
6942
  }
5732
- var researchLead = {
5733
- name: "leadbay_research_lead",
6943
+ function isReachable(c) {
6944
+ if (!c)
6945
+ return false;
6946
+ const email = typeof c.email === "string" ? c.email.trim() : "";
6947
+ const phone = typeof c.phone_number === "string" ? c.phone_number.trim() : "";
6948
+ return email.length > 0 || phone.length > 0;
6949
+ }
6950
+ var researchLeadById = {
6951
+ name: "leadbay_research_lead_by_id",
5734
6952
  annotations: {
5735
- title: "Research a Leadbay lead in depth",
6953
+ title: "Research a Leadbay lead in depth (by UUID)",
5736
6954
  readOnlyHint: true,
5737
6955
  destructiveHint: false,
5738
6956
  idempotentHint: true,
5739
6957
  openWorldHint: true
5740
6958
  },
5741
- description: leadbay_research_lead,
6959
+ description: leadbay_research_lead_by_id,
5742
6960
  inputSchema: {
5743
6961
  type: "object",
5744
6962
  properties: {
@@ -5889,16 +7107,31 @@ var researchLead = {
5889
7107
  },
5890
7108
  contacts: {
5891
7109
  type: "object",
5892
- description: "Two-tier contact set: `enriched` (paid contacts known on this lens for this lead) and `org` (org-level contacts visible beyond the lens).",
7110
+ description: "Two-tier contact set, partitioned by reachability \u2014 agent-friendly framing of the backend's paid-vs-org split. `reachable`: contacts with an email or phone right now (org-directory entries that ship with channels, PLUS paid contacts whose enrichment has completed). The agent can message these without buying enrichment. `candidates`: paid-contact entries WITHOUT resolved channels yet \u2014 typically LinkedIn URL only, `enrichment_done: false`. The agent must call leadbay_enrich_titles (or leadbay_prepare_outreach with enrich:true) before these become messagable.",
5893
7111
  properties: {
5894
- enriched: { type: "array", items: { type: "object" } },
5895
- org: { type: "array", items: { type: "object" } }
7112
+ reachable: { type: "array", items: { type: "object" } },
7113
+ candidates: { type: "array", items: { type: "object" } }
5896
7114
  },
5897
7115
  additionalProperties: false
5898
7116
  },
7117
+ recent_activities: {
7118
+ type: "array",
7119
+ description: "Unified activity timeline (top 20 most recent) from /leads/{id}/activities. Each entry: {type, date}. Replaces the per-category recent_notes/recent_epilogue/recent_prospecting arrays from the prior schema \u2014 counts stay on `engagement` as the cheap signal.",
7120
+ items: {
7121
+ type: "object",
7122
+ properties: {
7123
+ type: { type: "string" },
7124
+ date: { type: "string" }
7125
+ }
7126
+ }
7127
+ },
7128
+ web_insights_fetched_at: {
7129
+ type: ["string", "null"],
7130
+ description: "ISO timestamp of the latest /web_fetch run. Use as a staleness signal \u2014 if older than 30 days, offer to refresh."
7131
+ },
5899
7132
  engagement: {
5900
7133
  type: "object",
5901
- description: "What humans/prior agent runs already did: liked/disliked flags, recommended_contact, counts (notes/epilogue/prospecting), and the most-recent items (recent_notes, recent_epilogue, recent_prospecting). Counts > 0 trigger conditional fan-out for the recent_* fields.",
7134
+ description: "What humans/prior agent runs already did: liked/disliked flags, recommended_contact, and counts (notes/epilogue/prospecting). Recent items live in `recent_activities` at the top level.",
5902
7135
  properties: {
5903
7136
  liked: { type: "boolean" },
5904
7137
  disliked: { type: "boolean" },
@@ -5906,27 +7139,38 @@ var researchLead = {
5906
7139
  recommended_contact: { type: ["object", "null"] },
5907
7140
  notes_count: { type: "number" },
5908
7141
  epilogue_actions_count: { type: "number" },
5909
- prospecting_actions_count: { type: "number" },
5910
- recent_notes: { type: "array", items: { type: "object" } },
5911
- recent_epilogue: { type: "array", items: { type: "object" } },
5912
- recent_prospecting: { type: "array", items: { type: "object" } }
7142
+ prospecting_actions_count: { type: "number" }
5913
7143
  },
5914
7144
  additionalProperties: false
5915
7145
  },
5916
7146
  _meta: {
5917
7147
  type: "object",
5918
- description: "Operator context: region (us/fr/custom), lens_id (the lens used for the lead-by-id fetch), web_fetch_in_progress (true if the backend is still hydrating signals).",
7148
+ description: "Operator context: region (us/fr/custom), lens_id (the lens used for the lead-by-id fetch), web_fetch_in_progress (true if the backend is still hydrating signals), has_reachable_contact (true if at least one contact or recommended_contact has email or phone \u2014 drives NEXT STEPS routing between enrichment vs outreach). When the call was routed via leadbay_research_lead_by_name_fuzzy, also: resolved_from='companyName', resolved_query='<needle>', match_candidates=[{leadId,name,score}].",
5919
7149
  properties: {
5920
7150
  region: { type: "string" },
5921
7151
  lens_id: { type: "number" },
5922
- web_fetch_in_progress: { type: "boolean" }
7152
+ web_fetch_in_progress: { type: "boolean" },
7153
+ has_reachable_contact: { type: "boolean" },
7154
+ resolved_from: { type: ["string", "null"] },
7155
+ resolved_query: { type: ["string", "null"] },
7156
+ match_candidates: {
7157
+ type: ["array", "null"],
7158
+ items: { type: "object" }
7159
+ }
5923
7160
  },
5924
7161
  additionalProperties: false
5925
7162
  }
5926
7163
  },
5927
- required: ["qualification", "signals", "firmographics", "contacts", "engagement"]
7164
+ required: [
7165
+ "qualification",
7166
+ "signals",
7167
+ "firmographics",
7168
+ "contacts",
7169
+ "engagement",
7170
+ "recent_activities"
7171
+ ]
5928
7172
  },
5929
- execute: async (client, params, ctx) => {
7173
+ execute: async (client, params, _ctx) => {
5930
7174
  const lensId = params.lensId ?? await client.resolveDefaultLens();
5931
7175
  const leadId = params.leadId;
5932
7176
  void client.request("POST", "/interactions", [
@@ -5934,28 +7178,18 @@ var researchLead = {
5934
7178
  { type: "LEAD_CLICKED", leadId, lensId: String(lensId) }
5935
7179
  ]).catch(() => {
5936
7180
  });
5937
- const [profileR, qualR, contactsR, webFetchR] = await Promise.allSettled([
7181
+ const [profileR, qualR, contactsR, webFetchR, activitiesR, orgContactsR] = await Promise.allSettled([
5938
7182
  client.request("GET", `/lenses/${lensId}/leads/${leadId}`),
5939
7183
  client.request("GET", `/leads/${leadId}/ai_agent_responses`),
5940
7184
  client.request("GET", `/leads/${leadId}/enrich/contacts?IncludeEnriched=true`),
5941
- client.request("GET", `/leads/${leadId}/web_fetch`)
7185
+ client.request("GET", `/leads/${leadId}/web_fetch`),
7186
+ client.request("GET", `/leads/${leadId}/activities?count=20`),
7187
+ client.request("GET", `/leads/${leadId}/contacts?IncludeEnriched=true`)
5942
7188
  ]);
5943
7189
  if (profileR.status === "rejected") {
5944
7190
  throw profileR.reason;
5945
7191
  }
5946
7192
  const lead = profileR.value;
5947
- const wantNotes = (lead.notes_count ?? 0) > 0;
5948
- const wantEpilogue = (lead.epilogue_actions_count ?? 0) > 0;
5949
- const wantProspecting = (lead.prospecting_actions_count ?? 0) > 0;
5950
- const wantOrgContacts = (lead.org_contacts_count ?? 0) > 0;
5951
- const engagementFetches = await Promise.allSettled([
5952
- wantNotes ? client.request("GET", `/leads/${leadId}/notes`) : Promise.resolve(null),
5953
- wantEpilogue ? client.request("GET", `/leads/${leadId}/epilogue_responses?count=10&page=0`) : Promise.resolve(null),
5954
- wantProspecting ? client.request("GET", `/leads/${leadId}/prospecting_actions?count=10&page=0`) : Promise.resolve(null),
5955
- wantOrgContacts ? client.request("GET", `/leads/${leadId}/contacts?IncludeEnriched=true`) : Promise.resolve(null)
5956
- ]);
5957
- const [notesR, epilogueR, prospR, orgContactsR] = engagementFetches;
5958
- const valOrNull = (r) => r.status === "fulfilled" ? r.value ?? null : null;
5959
7193
  let signals = reshapeWebFetchContent(webFetchR.status === "fulfilled" ? webFetchR.value?.content ?? null : null);
5960
7194
  if (params.concise) {
5961
7195
  signals = signals.map((s) => ({
@@ -5964,7 +7198,42 @@ var researchLead = {
5964
7198
  })).filter((s) => s.entries.length > 0);
5965
7199
  }
5966
7200
  const paidContacts = contactsR.status === "fulfilled" ? contactsR.value : [];
5967
- const orgContacts = valOrNull(orgContactsR) ?? [];
7201
+ const orgContacts = orgContactsR.status === "fulfilled" ? orgContactsR.value : [];
7202
+ const shapePaid = (c) => ({
7203
+ id: c.id,
7204
+ first_name: c.first_name,
7205
+ last_name: c.last_name,
7206
+ job_title: c.job_title,
7207
+ email: c.email,
7208
+ phone_number: c.phone_number,
7209
+ linkedin_page: normalizeLinkedinPage5(c.linkedin_page),
7210
+ recommended: c.recommended,
7211
+ enrichment_done: c.enrichment?.done ?? false,
7212
+ source: "paid"
7213
+ });
7214
+ const shapeOrg = (c) => ({
7215
+ id: c.id,
7216
+ first_name: c.first_name,
7217
+ last_name: c.last_name,
7218
+ job_title: c.job_title,
7219
+ email: c.email,
7220
+ phone_number: c.phone_number ?? null,
7221
+ linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null),
7222
+ recommended: c.recommended,
7223
+ enrichment_done: true,
7224
+ source: "org"
7225
+ });
7226
+ const allContacts = [
7227
+ ...paidContacts.map(shapePaid),
7228
+ ...orgContacts.map(shapeOrg)
7229
+ ];
7230
+ const reachableContacts = allContacts.filter((c) => isReachable(c));
7231
+ const candidateContacts = allContacts.filter((c) => !isReachable(c));
7232
+ const recommendedContact = lead.recommended_contact ? {
7233
+ ...lead.recommended_contact,
7234
+ linkedin_page: normalizeLinkedinPage5(lead.recommended_contact.linkedin_page ?? null)
7235
+ } : null;
7236
+ const hasReachableContact = reachableContacts.length > 0 || isReachable(recommendedContact);
5968
7237
  const TRUNCATE_CHAR_BUDGET = 25e3;
5969
7238
  let truncated = false;
5970
7239
  let truncationHint = null;
@@ -5987,14 +7256,10 @@ var researchLead = {
5987
7256
  }));
5988
7257
  }
5989
7258
  }
7259
+ const recentActivities = activitiesR.status === "fulfilled" ? activitiesR.value.items.slice(0, 20).map((a) => ({ type: a.type, date: a.date })) : [];
7260
+ const webFetchFetchedAt = webFetchR.status === "fulfilled" ? webFetchR.value?.fetch_at ?? null : null;
5990
7261
  return {
5991
- // 1) qualification — single most important block for "is this lead worth pursuing"
5992
- // boost_score is the canonical field (per AiAgentResponse.score). The valid
5993
- // set is the discrete -10/0/10/20 boost (see types.ts comment), NOT a 0-10
5994
- // average — the eval doc flagged the old `score_0_to_10` field name as
5995
- // misleading. We now ship `boost_score` as canonical alongside an explicit
5996
- // `score_scale` annotation; `score_0_to_10` is kept as a deprecated alias
5997
- // for one minor version (0.6.x) and removed in 0.7.0. See MIGRATION.md.
7262
+ // 1) qualification
5998
7263
  qualification: qualR.status === "fulfilled" ? qualR.value.map((r) => ({
5999
7264
  question: r.question,
6000
7265
  boost_score: r.score,
@@ -6005,7 +7270,7 @@ var researchLead = {
6005
7270
  response: r.response,
6006
7271
  computed_at: r.computed_at
6007
7272
  })) : [],
6008
- // 2) signals — knowledge-base food (may be trimmed when truncated:true)
7273
+ // 2) signals
6009
7274
  signals: signalsForReturn,
6010
7275
  truncated,
6011
7276
  truncation_hint: truncationHint,
@@ -6030,59 +7295,40 @@ var researchLead = {
6030
7295
  social_urls: lead.social_urls ?? null,
6031
7296
  registry_ids: lead.registry_ids ?? null
6032
7297
  },
6033
- // 4) contacts (paid/enriched, plus org contacts if present)
6034
- // B6: defensively coerce the literal string "null" to a real null —
6035
- // some backend serializers emit it for un-enriched contacts.
7298
+ // 4) contacts partitioned by reachability (not by source endpoint)
6036
7299
  contacts: {
6037
- enriched: paidContacts.map((c) => ({
6038
- id: c.id,
6039
- first_name: c.first_name,
6040
- last_name: c.last_name,
6041
- job_title: c.job_title,
6042
- email: c.email,
6043
- phone_number: c.phone_number,
6044
- linkedin_page: normalizeLinkedinPage5(c.linkedin_page),
6045
- recommended: c.recommended,
6046
- enrichment_done: c.enrichment?.done ?? false
6047
- })),
6048
- org: orgContacts.map((c) => ({
6049
- id: c.id,
6050
- first_name: c.first_name,
6051
- last_name: c.last_name,
6052
- job_title: c.job_title,
6053
- email: c.email,
6054
- linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null)
6055
- }))
6056
- },
6057
- // 5) engagement — what humans/prior agent runs already did.
6058
- // B8: `recommended_contact_title` dropped — it duplicates
6059
- // `recommended_contact.job_title` and just confused agents.
7300
+ reachable: reachableContacts,
7301
+ candidates: candidateContacts
7302
+ },
7303
+ // 5) unified recent activity timeline
7304
+ recent_activities: recentActivities,
7305
+ // 6) staleness signal (for "refresh research" NEXT STEPS row)
7306
+ web_insights_fetched_at: webFetchFetchedAt,
7307
+ // 7) engagement — counts + curation flags only; recent items live in
7308
+ // recent_activities[].
6060
7309
  engagement: {
6061
7310
  liked: lead.liked,
6062
7311
  disliked: lead.disliked,
6063
7312
  new: lead.new ?? false,
6064
- recommended_contact: lead.recommended_contact ? {
6065
- ...lead.recommended_contact,
6066
- // B1+B7: propagate linkedin_page when the backend includes it.
6067
- linkedin_page: normalizeLinkedinPage5(lead.recommended_contact.linkedin_page ?? null)
6068
- } : null,
7313
+ recommended_contact: recommendedContact,
6069
7314
  notes_count: lead.notes_count ?? 0,
6070
7315
  epilogue_actions_count: lead.epilogue_actions_count ?? 0,
6071
- prospecting_actions_count: lead.prospecting_actions_count ?? 0,
6072
- recent_notes: valOrNull(notesR)?.slice(0, 3) ?? [],
6073
- recent_epilogue: valOrNull(epilogueR)?.items?.slice(0, 3) ?? [],
6074
- recent_prospecting: valOrNull(prospR)?.items?.slice(0, 5) ?? []
7316
+ prospecting_actions_count: lead.prospecting_actions_count ?? 0
6075
7317
  },
6076
7318
  _meta: {
6077
7319
  region: client.region,
6078
7320
  lens_id: lensId,
6079
- web_fetch_in_progress: webFetchR.status === "fulfilled" ? webFetchR.value?.in_progress : false
7321
+ web_fetch_in_progress: webFetchR.status === "fulfilled" ? webFetchR.value?.in_progress : false,
7322
+ has_reachable_contact: hasReachableContact,
7323
+ resolved_from: params._resolved?.from ?? null,
7324
+ resolved_query: params._resolved?.query ?? null,
7325
+ match_candidates: params._resolved?.candidates ?? null
6080
7326
  }
6081
7327
  };
6082
7328
  }
6083
7329
  };
6084
- var _innerExecute = researchLead.execute;
6085
- researchLead.execute = async (client, params, ctx) => {
7330
+ var _innerExecute = researchLeadById.execute;
7331
+ researchLeadById.execute = async (client, params, ctx) => {
6086
7332
  const result = await _innerExecute(client, params, ctx);
6087
7333
  if (params.response_format !== "markdown")
6088
7334
  return result;
@@ -6097,6 +7343,92 @@ researchLead.execute = async (client, params, ctx) => {
6097
7343
  return envelope;
6098
7344
  };
6099
7345
 
7346
+ // ../core/dist/composite/research-lead-by-name-fuzzy.js
7347
+ function rankSubstringMatches(needle, candidates) {
7348
+ const n = needle.toLowerCase();
7349
+ const hits = candidates.filter((c) => typeof c.name === "string" && c.name.toLowerCase().includes(n));
7350
+ hits.sort((a, b) => {
7351
+ const aScore = a.score ?? -Infinity;
7352
+ const bScore = b.score ?? -Infinity;
7353
+ return bScore - aScore;
7354
+ });
7355
+ return hits;
7356
+ }
7357
+ var researchLeadByNameFuzzy = {
7358
+ name: "leadbay_research_lead_by_name_fuzzy",
7359
+ annotations: {
7360
+ title: "Look up a lead by company name (fuzzy)",
7361
+ readOnlyHint: true,
7362
+ destructiveHint: false,
7363
+ idempotentHint: true,
7364
+ openWorldHint: true
7365
+ },
7366
+ description: leadbay_research_lead_by_name_fuzzy,
7367
+ inputSchema: {
7368
+ type: "object",
7369
+ properties: {
7370
+ companyName: {
7371
+ type: "string",
7372
+ description: "Company name to look up. Substring fuzzy-match against the top 50 of the active lens's wishlist; ties broken by descending lead score."
7373
+ },
7374
+ lensId: {
7375
+ type: "number",
7376
+ description: "Lens id (escape hatch \u2014 normally omit; auto-resolves to the active lens)"
7377
+ },
7378
+ concise: {
7379
+ type: "boolean",
7380
+ description: "Forwarded to leadbay_research_lead_by_id. If true, trims signals to hot=true items only."
7381
+ },
7382
+ response_format: {
7383
+ type: "string",
7384
+ enum: ["json", "markdown"],
7385
+ description: "Forwarded to leadbay_research_lead_by_id. Default 'json'."
7386
+ }
7387
+ },
7388
+ required: ["companyName"],
7389
+ additionalProperties: false
7390
+ },
7391
+ // Output shape matches leadbay_research_lead_by_id; the only additions are
7392
+ // _meta.resolved_from / resolved_query / match_candidates which are
7393
+ // documented on _by_id's output schema. Defer to _by_id for the schema —
7394
+ // duplicating it would just rot.
7395
+ outputSchema: {
7396
+ type: "object",
7397
+ description: "Same shape as leadbay_research_lead_by_id, with _meta.resolved_from='companyName', _meta.resolved_query='<needle>', and _meta.match_candidates=[{leadId,name,score}] populated.",
7398
+ additionalProperties: true
7399
+ },
7400
+ execute: async (client, params, ctx) => {
7401
+ if (!params.companyName || typeof params.companyName !== "string") {
7402
+ throw client.makeError("INVALID_PARAMS", "companyName is required", "Pass the company name as a string. If you already have the lead UUID, call leadbay_research_lead_by_id directly.");
7403
+ }
7404
+ const lensId = params.lensId ?? await client.resolveDefaultLens();
7405
+ const results = await discoverLeads.execute(client, { lensId, count: 50, page: 0 }, ctx);
7406
+ const allLeads = results.leads;
7407
+ const ranked = rankSubstringMatches(params.companyName, allLeads);
7408
+ if (ranked.length === 0) {
7409
+ const nearestNames = [...allLeads].sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity)).slice(0, 5).map((l) => ({ leadId: l.id, name: l.name, score: l.score }));
7410
+ throw client.makeError("LEAD_NOT_FOUND", `No lead matching "${params.companyName}" in the current lens (top-50 wishlist)`, `Call leadbay_pull_leads to see what's available. Top-scoring leads currently in this lens: ${nearestNames.map((n) => n.name).join(", ")}.`);
7411
+ }
7412
+ const [primary, ...rest] = ranked;
7413
+ const candidates = rest.slice(0, 4).map((m) => ({
7414
+ leadId: m.id,
7415
+ name: m.name,
7416
+ score: m.score
7417
+ }));
7418
+ return await researchLeadById.execute(client, {
7419
+ leadId: primary.id,
7420
+ lensId,
7421
+ concise: params.concise,
7422
+ response_format: params.response_format,
7423
+ _resolved: {
7424
+ from: "companyName",
7425
+ query: params.companyName,
7426
+ candidates
7427
+ }
7428
+ }, ctx);
7429
+ }
7430
+ };
7431
+
6100
7432
  // ../core/dist/composite/recall-ordered-titles.js
6101
7433
  var recallOrderedTitles = {
6102
7434
  name: "leadbay_recall_ordered_titles",
@@ -8929,7 +10261,7 @@ var enrichTitles = {
8929
10261
  launched_at: bulkRecord?.launched_at,
8930
10262
  durability: bulkRecord?.durability,
8931
10263
  message: bulkRecord ? "Enrichment job launched. Backend has no server-side bulk_id yet; MCP minted a client-side bulk_id (persisted to disk by default) so you can poll via leadbay_bulk_enrich_status." : "Enrichment job launched. No bulk_id tracker configured \u2014 poll leadbay_get_contacts per lead after ~60s; contact.enrichment.done flips to true.",
8932
- next_action: bulkRecord ? "Call leadbay_bulk_enrich_status({bulk_id}) after ~60s; pass include_contacts=true for the final read." : "Wait ~60s, then call leadbay_research_lead or leadbay_get_contacts on the leads you care about."
10264
+ next_action: bulkRecord ? "Call leadbay_bulk_enrich_status({bulk_id}) after ~60s; pass include_contacts=true for the final read." : "Wait ~60s, then call leadbay_research_lead_by_id or leadbay_get_contacts on the leads you care about."
8933
10265
  };
8934
10266
  } finally {
8935
10267
  try {
@@ -9211,9 +10543,9 @@ function bestMatches(text, taxonomy) {
9211
10543
  return ranked.slice(0, 5);
9212
10544
  }
9213
10545
  async function resolveSectors(client, texts) {
9214
- const looksLikeId = (s) => /^\d+$/.test(s);
9215
- const direct = texts.filter(looksLikeId);
9216
- const free = texts.filter((s) => !looksLikeId(s));
10546
+ const looksLikeId2 = (s) => /^\d+$/.test(s);
10547
+ const direct = texts.filter(looksLikeId2);
10548
+ const free = texts.filter((s) => !looksLikeId2(s));
9217
10549
  if (free.length === 0)
9218
10550
  return { resolved: direct, ambiguities: [] };
9219
10551
  const me = await client.resolveMe().catch(() => null);
@@ -10032,6 +11364,7 @@ var granularReadTools = [
10032
11364
  getLensFilter,
10033
11365
  getLensScoring,
10034
11366
  listSectors,
11367
+ listLocations,
10035
11368
  getUserPrompt,
10036
11369
  getClarification,
10037
11370
  getLeadNotes,
@@ -10040,7 +11373,9 @@ var granularReadTools = [
10040
11373
  getWebFetch,
10041
11374
  getSelectionIds,
10042
11375
  getEnrichmentJobTitles,
10043
- listMappableFields
11376
+ listMappableFields,
11377
+ createTopupLink,
11378
+ openBillingPortal
10044
11379
  ];
10045
11380
  var granularWriteTools = [
10046
11381
  qualifyLead,
@@ -10065,7 +11400,9 @@ var granularWriteTools = [
10065
11400
  removePushback,
10066
11401
  previewBulkEnrichment,
10067
11402
  launchBulkEnrichment,
10068
- createCustomField
11403
+ createCustomField,
11404
+ likeLead,
11405
+ dislikeLead
10069
11406
  ];
10070
11407
  var granularTools = [
10071
11408
  login,
@@ -10078,7 +11415,9 @@ granularTools.forEach((t) => {
10078
11415
  var compositeReadTools = [
10079
11416
  pullLeads,
10080
11417
  pullFollowups,
10081
- researchLead,
11418
+ followupsMap,
11419
+ researchLeadById,
11420
+ researchLeadByNameFuzzy,
10082
11421
  recallOrderedTitles,
10083
11422
  accountStatus,
10084
11423
  bulkEnrichStatus,
@@ -10089,8 +11428,14 @@ var compositeReadTools = [
10089
11428
  // it for discoverability; expose it always-on so agents can find custom fields
10090
11429
  // without needing LEADBAY_MCP_ADVANCED=1.
10091
11430
  listMappableFields,
10092
- // Keep the existing composites available too.
10093
- researchCompany,
11431
+ // Billing / top-up tools granular-shaped but ALWAYS exposed because
11432
+ // they're the canonical recovery path from a QUOTA_EXCEEDED wall. If
11433
+ // they were gated behind LEADBAY_MCP_ADVANCED=1 the agent would
11434
+ // know about the wall but not the door out. Read-only from the
11435
+ // agent's POV (creating a Stripe session URL doesn't charge anyone;
11436
+ // the user pays in their browser).
11437
+ createTopupLink,
11438
+ openBillingPortal,
10094
11439
  prepareOutreach
10095
11440
  ];
10096
11441
  var compositeWriteTools = [
@@ -10107,7 +11452,11 @@ var compositeWriteTools = [
10107
11452
  createCustomField,
10108
11453
  // addNote is granular-shaped but file-import prompts depend on it to preserve
10109
11454
  // meaningful source-file notes after imports return lead ids.
10110
- addNote
11455
+ addNote,
11456
+ // likeLead/dislikeLead are granular-shaped but should always be available
11457
+ // to the agent without requiring LEADBAY_MCP_ADVANCED=1.
11458
+ likeLead,
11459
+ dislikeLead
10111
11460
  ];
10112
11461
  var compositeTools = [
10113
11462
  ...compositeReadTools,
@@ -10137,6 +11486,7 @@ export {
10137
11486
  getLensFilter,
10138
11487
  getLensScoring,
10139
11488
  listSectors,
11489
+ listLocations,
10140
11490
  getUserPrompt,
10141
11491
  getClarification,
10142
11492
  getLeadNotes,
@@ -10147,6 +11497,8 @@ export {
10147
11497
  getEnrichmentJobTitles,
10148
11498
  importLeads,
10149
11499
  listMappableFields,
11500
+ createTopupLink,
11501
+ openBillingPortal,
10150
11502
  selectLeads,
10151
11503
  deselectLeads,
10152
11504
  clearSelection,
@@ -10167,11 +11519,14 @@ export {
10167
11519
  previewBulkEnrichment,
10168
11520
  launchBulkEnrichment,
10169
11521
  createCustomField,
10170
- researchCompany,
11522
+ likeLead,
11523
+ dislikeLead,
10171
11524
  prepareOutreach,
10172
11525
  pullLeads,
10173
11526
  pullFollowups,
10174
- researchLead,
11527
+ followupsMap,
11528
+ researchLeadById,
11529
+ researchLeadByNameFuzzy,
10175
11530
  recallOrderedTitles,
10176
11531
  accountStatus,
10177
11532
  bulkQualifyLeads,