@leadbay/mcp 0.10.1 → 0.12.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.
@@ -586,6 +586,50 @@ WHEN TO USE: at the start of a session to know what the agent can/can't do, afte
586
586
 
587
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.
588
588
  `;
589
+ var leadbay_add_leads_to_campaign = `## WHEN TO USE
590
+
591
+ Trigger phrases: "add these leads to my <name> campaign", "attach these to <campaign>", "put these 5 in the Q2 Push campaign", "add to existing campaign".
592
+
593
+ Do NOT use for: "create a new campaign (not attach to an existing one)" \u2192 \`leadbay_create_campaign\`; "remove a lead from a campaign" \u2192 \`leadbay_create_campaign\`; "list which campaigns I have" \u2192 \`leadbay_list_campaigns\`.
594
+
595
+ Prefer when: user names an existing campaign (or supplies a campaign_id) AND a set of leads to attach. If they want a new campaign, use create_campaign with seed lead_ids instead
596
+
597
+ Examples that SHOULD invoke this tool:
598
+ - "Add the three new Tulsa leads to my 'OK Sweep' campaign."
599
+ - "Attach these qualified leads to campaign id 1f12...?"
600
+ - "Put the top 5 of today's batch into my Q2 Push."
601
+
602
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
603
+ - "Create a new campaign with these 9 leads."
604
+ - "What campaigns do I have?"
605
+ - "Show progression on my Limoges Tour."
606
+
607
+ ## RENDER (quick)
608
+
609
+ One-line confirmation: \u2705 Added N leads to <campaign-name>
610
+ (M already present). If \`added === 0 && already_present > 0\` ("all
611
+ already in"), surface that as a no-op note instead of a success
612
+ banner. Offer NEXT STEPS chip for "View progression"
613
+ (campaign_progression) or "Add more".
614
+
615
+ ---
616
+
617
+ Attach a list of \`lead_ids\` to an existing campaign. Wraps \`POST /campaigns/{id}/leads\`. The backend dedups against \`campaign_leads\`, so callers can safely retry without creating duplicates \u2014 the response separates \`added\` (newly attached) from \`already_present\` (no-op).
618
+
619
+ **Lead UUID source**: pass UUIDs returned from \`leadbay_pull_leads\` (\`lead.id\`), \`leadbay_pull_followups\` (\`lead.id\`), \`leadbay_tour_plan\` (\`monitor_leads[].id\` / \`discover_leads[].id\`), \`leadbay_research_lead_by_id\` / \`_by_name_fuzzy\` (\`lead.id\`). Backend \`ensureLeadsExist\` rejects unknown UUIDs with 404 \u2014 typo'd or fabricated IDs throw \`NOT_FOUND\`.
620
+
621
+ **Why batch vs. one-at-a-time**: every call rebuilds membership server-side and may regenerate the AI-suggested name (\`maybeRegenerateAiName\`). Prefer one call with N leads over N calls with one lead each.
622
+
623
+ **Side effect**: \`ensureLeadsInMonitor(orgId, leadIds)\` is called after a successful add \u2014 leads not already in the Monitor view get added there. This means adding a "fresh Discover" lead to a campaign moves it into the user's known-pipeline view.
624
+
625
+ ---
626
+
627
+ WHEN TO USE: the user has an existing campaign (created earlier in the session or visible via \`leadbay_list_campaigns\`) and wants to attach more leads to it.
628
+
629
+ WHEN NOT TO USE: to create a NEW campaign (use \`leadbay_create_campaign\` with \`lead_ids\` seeded); to list campaigns (\`leadbay_list_campaigns\`); to view per-lead progression (\`leadbay_campaign_progression\`); to remove leads (not currently exposed in the MCP \u2014 direct backend \`DELETE /campaigns/{id}/leads\` would be needed).
630
+
631
+ **Response**: \`{added: number, already_present: number}\`. Use both counts in the confirmation \u2014 silent dedup hides useful information from the user.
632
+ `;
589
633
  var leadbay_add_note = `Add a note to a lead. Notes are visible to the whole organization in Leadbay.
590
634
 
591
635
  WHEN TO USE: low-level \u2014 for free-form notes not tied to outreach actions, including meaningful per-lead notes/context preserved from an imported file after the import returns lead IDs.
@@ -691,6 +735,214 @@ Exactly two offers \u2014 keep it terse, this is a status tool:
691
735
  | Qualification kicked off (async) | "Check progress in ~30s" | leadbay_qualify_status |
692
736
  | Job is done / blocking call returned | "Refresh leads view \u2014 the new qualifications should be on the top" | leadbay_pull_leads(lensId = pinned) |
693
737
  `;
738
+ var leadbay_campaign_call_sheet = `## WHEN TO USE
739
+
740
+ Trigger phrases: "show me my campaign as a call sheet", "list of people to call in <campaign>", "cold-calling cheat sheet", "work this campaign", "let's call through my <campaign>", "calling session for <campaign>".
741
+
742
+ Do NOT use for: "campaign progression pulse only (no contacts)" \u2192 \`leadbay_campaign_progression\`; "create a new campaign" \u2192 \`leadbay_create_campaign\`; "list all my campaigns" \u2192 \`leadbay_list_campaigns\`.
743
+
744
+ Prefer when: user wants an actionable call-ready view of ONE campaign with phones + LinkedIn ready to click. Pair with the \`leadbay_work_campaign\` prompt for the full dictation+epilogue loop
745
+
746
+ Examples that SHOULD invoke this tool:
747
+ - "Show me my Limoges Tour campaign as a call sheet."
748
+ - "I'm about to do a calling session \u2014 render the Q2 Push campaign."
749
+ - "Give me phones + LinkedIn for everyone in my OK Sweep campaign."
750
+
751
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
752
+ - "Quick pulse on how my campaigns are doing."
753
+ - "Save these 9 leads as a new campaign."
754
+ - "Tell me about Acme Corp."
755
+
756
+ ## RENDER (quick)
757
+
758
+ Per-lead CARD sorted by AI score desc. Heading \`### \u26A1 **<Company>**
759
+ \u2014 <City>, <State>\`; one-line "\u2605 Next step: \u2026" from
760
+ split_ai_summary; then a 4-col table of contacts:
761
+ Contact / Phone / Role / Recent. Cell 1 stacks linked name + email
762
+ + (if constructed) LinkedIn marker \xB0. Phone is \`[bare](tel:URL)\`.
763
+ Recent stacks last note + lead headline. Top of page = summary chip
764
+ from \`summary\` + readiness flags.
765
+
766
+ ---
767
+
768
+ Build a cold-calling cheat sheet for one campaign. The composite joins two backend reads \u2014 \`/campaigns/{id}/contacts\` (per-contact phones, LinkedIn URLs, emails, job titles, recent notes) and \`/campaigns/{id}/leads\` (per-lead AI next-step, location, score, progress) \u2014 into a single call-ready payload aligned to the lead's page order.
769
+
770
+ **Per-lead block** carries:
771
+
772
+ - \`lead_id\`, \`lead_name\`, \`score\`, \`ai_agent_lead_score\`
773
+ - \`location\` (\`city\`, \`state\`, \`country\`, \`full\`, \`pos[lat,lng]\`)
774
+ - \`website\`, \`company_phone_numbers[]\` (often empty; use per-contact phones)
775
+ - \`next_step\` / \`approach_angle\` / \`worth_pursuing\` (from \`split_ai_summary\` \u2014 the structured calling angle)
776
+ - \`progress\` (\`total_contacts\` = reachable contact coverage; \`in_progress\` / \`declined\` / \`headline\` = outreach state \u2014 use those, not \`total_contacts\`, to tell whether the lead has been touched)
777
+ - \`affiliation.own_campaigns[]\` + \`other_users_campaign_count\` (overlap warning \u2014 avoid double-touching)
778
+ - \`contacts[]\` \u2014 every reachable contact for this lead, sorted: AI-pinned first, then recommended, then by phone availability. Each contact has:
779
+ - \`first_name\`, \`last_name\`, \`full_name\`, \`job_title\`
780
+ - \`phone_number\` (bare, e.g. \`"+1 512-792-7708"\`), \`phone_tel_url\` (canonical, e.g. \`"tel:+15127927708"\`)
781
+ - \`email\`, \`mailto_url\`
782
+ - \`linkedin_url\` + \`linkedin_url_source\` (\`"leadbay"\` when the backend has it, \`"constructed"\` when fallback-built from name + company)
783
+ - \`recent_notes[]\` \u2014 recent notes about this contact (often empty on cold campaigns)
784
+
785
+ **Map mode**: the response also carries \`map_locations[]\` \u2014 already-shaped entries for \`places_map_display_v0\` (\`{name, address, latitude, longitude, notes}\`). When the user wants a route ("plot my call list on a map"), pass \`map_locations\` directly into the widget without reshaping.
786
+
787
+ **Cross-campaign overlap warning**: when a contact's lead shows \`affiliation.other_users_campaign_count > 0\`, surface that ("\u26A0 2 teammates also have this lead in their own campaigns") so the rep can coordinate before calling.
788
+
789
+ ---
790
+
791
+ ## RENDER \u2014 call-sheet markdown (PRIMARY)
792
+
793
+ This is a cold-calling surface. The user wants to scan \u2192 pick \u2192 tap-to-call \u2192 dictate outcome \u2192 next. Render one CARD per lead, sorted by AI score:
794
+
795
+ \`\`\`
796
+ ### \u26A1 **<Company Name>** \u2014 <City>, <State>
797
+
798
+ **Score <ai_agent_lead_score or score>** \xB7 \u2605 Next step: <next_step>
799
+
800
+ | Contact | Phone | Role | Recent |
801
+ |---|---|---|---|
802
+ | **[<First Last>](<linkedin_url>)**<br>\u2709 [<email>](<mailto_url>) | [<phone bare>](<phone_tel_url>) | <job_title> | \u{1F4DD} "<last_note truncated>" (<rel_date>)<br>\u{1F4DE} <last_action_headline> |
803
+ | **[<First Last>](<linkedin_url>)** | [<phone bare>](<phone_tel_url>) | <job_title> | _(no notes)_ |
804
+
805
+ <short callout \u2014 "\u{1F4DE} 0 prior touches" / "\u26A0 Also in 2 teammates' campaigns" if applicable>
806
+
807
+ ---
808
+ \`\`\`
809
+
810
+ **Cell 1 \u2014 Contact (stacked content)**:
811
+ - Line 1: \`**[<First Last>](<linkedin_url>)**\` \u2014 MUST always be a markdown link. Use \`contact.linkedin_url\` from the response (the composite already falls back to a \`linkedin.com/search/results/people/?keywords=\u2026\` URL when \`linkedin_page\` is missing \u2014 never render a bare name). When \`linkedin_url_source === "constructed"\`, append a trailing \` \xB0\` to the contact name to mark the fallback path.
812
+ - Line 2 (if email present): \`\u2709 [<email>](<mailto_url>)\` \u2014 \`mailto:\` link, auto-linkifies and opens the user's default mail app. Omit the line entirely if \`email === null\` rather than rendering "_no email_".
813
+
814
+ **Cell 2 \u2014 Phone (load-bearing for one-tap calling on mobile)**:
815
+ - Always render as a markdown link \`[bare](tel:URL)\`. The bare text auto-linkifies on most hosts; the explicit \`tel:\` link guarantees one-tap dialing on macOS / iOS / Android.
816
+ - Use the \`phone_tel_url\` from the response (already canonicalized to \`tel:+<digits>\`); do NOT reconstruct.
817
+ - When \`phone_number\` is null but the lead has \`company_phone_numbers[]\`, fall back to the company switchboard with a "(company line)" suffix.
818
+ - When neither contact phone nor company phone is present, render \`_no phone_\` \u2014 the rep will know this contact needs enrichment.
819
+
820
+ **Cell 3 \u2014 Role**: \`contact.job_title\`. Render \`\u2014\` when null.
821
+
822
+ **Cell 4 \u2014 Recent (stacked content, history surface)**:
823
+ - Line 1 (if \`contact.recent_notes[0]\` present): \`\u{1F4DD} "<note text, truncated to ~60 chars>" (<relative date>)\`. The relative date uses the note's \`created_at\` ISO timestamp \u2014 render as \`3d ago\`, \`2w ago\`, etc. Omit the line entirely if no notes.
824
+ - Line 2 (if \`last_action_headline\` on the lead-level block present): \`\u{1F4DE} <headline>\` \u2014 e.g. \`\u{1F4DE} CONTACTED\`, \`\u{1F4DE} MEETING_BOOKED\`. This is the LEAD's most recent interaction headline, not the contact's. Helpful for context but the per-contact note above is more specific.
825
+ - When both are absent, omit the cell content entirely \u2014 don't render \`_(no activity)_\` for every cold contact; it's noise.
826
+
827
+ **Sort + filter**:
828
+ - Top of the page: ONE-line summary chip from \`summary\` \u2014 \`\u{1F4CB} 12 leads \xB7 23 contacts \xB7 9 with a phone \xB7 7 with an email \xB7 3 already touched\`.
829
+ - Lead cards sorted by AI score desc; contacts within each card sorted AI-pinned > recommended > has-phone.
830
+ - If \`last_action_headline\` is present OR \`progress.in_progress > 0\` OR \`progress.declined > 0\`, render the card with a \u{1F4DE} prefix instead of \u26A1 \u2014 at-a-glance "already touched" vs "still cold". Do not use \`progress.total_contacts\`; that is contact coverage, not outreach history.
831
+
832
+ ## RENDER \u2014 map widget (SECONDARY, when user asks for a route)
833
+
834
+ When the user says "plot my call list on a map" / "where are these leads" / "route my calling tour", route to \`places_map_display_v0\` using the \`map_locations\` array verbatim:
835
+
836
+ \`\`\`
837
+ places_map_display_v0({
838
+ locations: response.map_locations,
839
+ travel_mode: "driving"
840
+ })
841
+ \`\`\`
842
+
843
+ The composite has already built the notes string per place card (one sentence with the top contact's phone inline). After the widget, emit the standard call-sheet card list below it for the rich detail \u2014 the carousel renders bare phones as \`tel:\` but strips markdown.
844
+
845
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
846
+
847
+ 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.
848
+
849
+ **The Big Three** \u2014 when a tool result fits, route there:
850
+
851
+ | Host widget | Use when | Field map (from Leadbay payload) |
852
+ |---|---|---|
853
+ | \`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 |
854
+ | \`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") |
855
+ | \`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 |
856
+
857
+ 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.
858
+
859
+ **Rules:**
860
+ - 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.
861
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
862
+ - 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.
863
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
864
+
865
+
866
+ ---
867
+
868
+ ## NEXT STEPS \u2014 wire the dictation+epilogue loop
869
+
870
+ After the user reports a call ("Called Bree, she wants pricing"), route to \`leadbay_report_outreach\` with BOTH \`note\` (the call summary) AND \`epilogue_status\` in one call. The four epilogue values:
871
+
872
+ - \`STILL_CHASING\` \u2014 still pursuing, no decision yet.
873
+ - \`COULD_NOT_REACH_STILL_TRYING\` \u2014 voicemail / no answer.
874
+ - \`INTEREST_VALIDATED_OR_MEETING_PLANED\` \u2014 qualified / meeting booked.
875
+ - \`NOT_INTERESTED_LOST\` \u2014 declined / not a fit.
876
+
877
+ The \`verification\` field is REQUIRED \u2014 pass \`{source: "user_confirmed", ref: <user's exact words>}\` since calls don't have message-ids.
878
+
879
+ WHEN TO USE: the user wants to actually WORK a campaign \u2014 calling session, dictation loop, follow-up sequence. Pair with the \`leadbay_work_campaign\` prompt for the end-to-end orchestrator (pick campaign \u2192 render \u2192 call \u2192 record loop).
880
+
881
+ WHEN NOT TO USE: for cross-campaign pulse (use \`leadbay_list_campaigns\`); for the slim progression view without contacts (\`leadbay_campaign_progression\` \u2014 same per-lead progress but no phone/LinkedIn fan-out); to log outreach AFTER a call (chain into \`leadbay_report_outreach\`).
882
+
883
+ **Response envelope**: \`{campaign_id, leads[], map_locations[], summary, pagination, _meta}\`.
884
+
885
+ ## Linking a contact's name
886
+
887
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
888
+
889
+ URL priority (first applicable wins):
890
+
891
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
892
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
893
+
894
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
895
+ `;
896
+ var leadbay_campaign_progression = `## WHEN TO USE
897
+
898
+ Trigger phrases: "how is my <name> campaign doing", "campaign progression", "lead-by-lead status on <campaign>", "who in <campaign> have I contacted", "what's stuck in my campaign".
899
+
900
+ Do NOT use for: "pulse across all campaigns (not one)" \u2192 \`leadbay_list_campaigns\`; "log an outreach event" \u2192 \`leadbay_report_outreach\`.
901
+
902
+ Prefer when: user named (or just selected from list_campaigns) ONE campaign and wants per-lead status. Use list_campaigns for the cross-campaign overview
903
+
904
+ Examples that SHOULD invoke this tool:
905
+ - "Walk me through the Limoges Tour campaign \u2014 who have I touched?"
906
+ - "Show progression on campaign 1f12...?"
907
+ - "What's stuck in my Q2 Push?"
908
+
909
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
910
+ - "Show me all my campaigns."
911
+ - "Create a new campaign."
912
+ - "Log that I emailed Acme."
913
+
914
+ ## RENDER (quick)
915
+
916
+ Two-tier render. Top: one-line summary
917
+ ("Limoges Tour: 9 leads \xB7 4 contacted \xB7 1 meeting \xB7 0 declined").
918
+ Then a markdown table per lead:
919
+ | Lead | Score | Contacts | Last action | Also in |
920
+ Each row's "Also in" column lists \`affiliation.own_campaigns[]\`
921
+ names (with a \u26A0 suffix if \`other_users_campaign_count > 0\`,
922
+ indicating teammates also have it). Sort by progress.headline
923
+ recency or by score desc \u2014 pick whichever surfaces "needs
924
+ attention" leads first.
925
+
926
+ ---
927
+
928
+ Per-lead progression view for one campaign. Wraps \`GET /campaigns/{id}/leads\` (paginated). For every lead in the campaign:
929
+
930
+ - **lead** \u2014 full \`LeadPayload\` (same shape \`pull_leads\` / \`pull_followups\` return), so the agent has score, contacts, ai_summary, location, etc. without an extra round trip.
931
+ - **progress** \u2014 \`{total_contacts, in_progress, declined, headline}\`. \`total_contacts\` is reachable contact coverage; do not treat it as prior outreach. \`headline\` is the most recent \`InteractionType\` (CONTACTED / MEETING_BOOKED / DECLINED / etc.) and is the load-bearing field for "what stage is this lead at".
932
+ - **affiliation** \u2014 \`{own_campaigns: CampaignRefPayload[], other_users_campaign_count}\`. \`own_campaigns\` lists OTHER campaigns of yours this lead is in (overlap detection \u2014 avoids the rep doubling outreach across two of their own campaigns). \`other_users_campaign_count\` is how many teammates also have this lead in one of THEIR campaigns. This is the only cross-user signal the campaign API exposes.
933
+
934
+ The composite also adds a \`summary\` block computed across the current page (\`{page_size, contacted, in_progress, declined}\`) so a quick "how is this campaign doing" prompt doesn't need to roll up the items array itself.
935
+
936
+ **Pagination**: \`count\` defaults to 50; \`page\` is 0-indexed. For campaigns with hundreds of leads, page through and concatenate the summary counts.
937
+
938
+ ---
939
+
940
+ WHEN TO USE: after \`leadbay_list_campaigns\` (or when the user named a specific campaign): the manager wants per-lead status to spot leads needing re-engagement, leads stuck, or leads that progressed to a meeting.
941
+
942
+ WHEN NOT TO USE: for cross-campaign pulse (use \`leadbay_list_campaigns\`); to drill into a lead's full timeline (use \`leadbay_get_lead_activities\` or \`leadbay_research_lead_by_id\`); to log outreach (\`leadbay_report_outreach\`).
943
+
944
+ **Response**: \`{items, pagination, summary, _meta}\`. Use the \`summary\` for the one-line headline; use \`items\` for the per-lead table.
945
+ `;
694
946
  var leadbay_clear_selection = `Clear the user's transient selection.
695
947
 
696
948
  WHEN TO USE: cleanup after manual selection work, or recovery from a stuck composite.
@@ -707,6 +959,55 @@ WHEN NOT TO USE: to replace with a different prompt \u2014 just call leadbay_ref
707
959
 
708
960
  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\`.
709
961
  `;
962
+ var leadbay_create_campaign = `## WHEN TO USE
963
+
964
+ Trigger phrases: "create a campaign called <name>", "save these leads as a campaign", "make a campaign for my <city> trip", "track these leads together", "group these leads into a named campaign", "persist these leads".
965
+
966
+ Do NOT use for: "list my existing campaigns" \u2192 \`leadbay_list_campaigns\`; "add leads to an existing campaign" \u2192 \`leadbay_add_leads_to_campaign\`; "log an outreach event" \u2192 \`leadbay_report_outreach\`.
967
+
968
+ Prefer when: user wants to persist a hand-picked set of leads as a named cohort they'll work through. Often follows leadbay_pull_leads / leadbay_tour_plan / leadbay_research_lead_by_id
969
+
970
+ Examples that SHOULD invoke this tool:
971
+ - "Save these 9 leads as a campaign called 'Limoges Tour \u2013 May 24'."
972
+ - "Create a campaign for the qualified leads I just picked."
973
+ - "Make a campaign for my SF visit and add those three accounts."
974
+
975
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
976
+ - "What campaigns do I have?"
977
+ - "Add three more leads to my 'Q2 Push' campaign."
978
+ - "I just emailed Acme \u2014 log it."
979
+
980
+ ## RENDER (quick)
981
+
982
+ One-line confirmation: \u2705 Created **<name>** with N leads \xB7 <id-short>.
983
+ If the backend AI-generated the name (no \`name\` passed), surface that:
984
+ "AI-suggested name: <name>". Offer a NEXT STEPS chip for "Add more
985
+ leads" or "Open in web UI". Don't dump the full campaign payload as a
986
+ table \u2014 the user only needs the confirmation + the id for follow-up.
987
+
988
+ ---
989
+
990
+ Create a new campaign \u2014 a server-persisted grouping of leads the user (or their manager) plans to work systematically. Wraps \`POST /campaigns\` (see \`.context/campaigns-probe/API.md\` for the discovered shape). Body is snake_case (the backend's \`apiJson\` uses \`JsonNamingStrategy.SnakeCase\`); this composite handles that translation \u2014 agents pass camelCase via the input schema but the wire is snake_case.
991
+
992
+ **Name behavior**:
993
+ - Pass \`name\` explicitly when the user named it ("Limoges Tour \u2013 May 24"). Max 255 chars.
994
+ - Omit \`name\` AND pass non-empty \`lead_ids\` \u2192 backend calls \`SuggestCampaignName.generate()\` to AI-pick a name from the seed leads. Returned as both \`name\` (final) and \`ai_generated_name\` (the suggestion, in case the user wants to rename later).
995
+ - Omit both \u2192 backend assigns a default (e.g. "Untitled campaign").
996
+
997
+ **Seed leads vs. empty**:
998
+ - Seed with \`lead_ids: [...]\` when the user already picked the leads (chain after \`leadbay_tour_plan\`, \`leadbay_pull_leads\`, \`leadbay_research_lead_by_id\`).
999
+ - Create empty (\`lead_ids: []\`, default) and add later via \`leadbay_add_leads_to_campaign\` \u2014 useful when the user named the campaign first and wants to populate it incrementally.
1000
+
1001
+ **Scope**: campaigns are created in the caller's organization and \`created_by = caller_user_id\`. The list endpoint (\`leadbay_list_campaigns\`) is filtered to the creator \u2014 campaigns ARE NOT shared with teammates by default. For #3630 US3 "manager creates a campaign for a rep", today's MCP workaround is to name campaigns descriptively ("North-East \u2013 John") and have the rep visit /app to access via the web UI; cross-user assignment would need backend work.
1002
+
1003
+ ---
1004
+
1005
+ WHEN TO USE: the user wants to persist a hand-picked set of leads as a named cohort they'll work through systematically. Typical chains: \`tour_plan \u2192 create_campaign(lead_ids=[...selected])\` for trip planning, or \`pull_leads \u2192 research_lead_by_id \u2192 create_campaign\` for incremental promising-lead curation.
1006
+
1007
+ WHEN NOT TO USE: to LIST existing campaigns (use \`leadbay_list_campaigns\`); to ADD leads to an existing one (use \`leadbay_add_leads_to_campaign\`); to LOG an outreach event (use \`leadbay_report_outreach\`); to view per-lead campaign progression (use \`leadbay_campaign_progression\`).
1008
+
1009
+ **Response**: full \`CampaignPayload\` \u2014 \`{id, name, ai_generated_name?, ai_name_count, archived, created_by, created_at, updated_at, last_accessed_at}\`. Echo \`id\` back to the user as the handle for follow-up tool calls.
1010
+ `;
710
1011
  var leadbay_create_custom_field = `Create an org-level CRM custom field for imports, then use the returned \`mapping_value\` in leadbay_import_leads / leadbay_import_and_qualify mappings. Use when the user's file contains valuable columns that do not fit Leadbay's standard fields, such as source-system deep links, source record IDs, campaign provenance, or user-requested enrichment attributes.
711
1012
 
712
1013
  For HubSpot record links, prefer \`type:'EXTERNAL_ID'\` with \`config.url_template\` and import only the stable HubSpot id as the CSV value. Example: create field name 'HubSpot Contact', type 'EXTERNAL_ID', config \`{url_template:'https://app.hubspot.com/contacts/<portal-id>/record/0-1/{value}'}\`; then map \`hubspot_id\` to the returned \`mapping_value\`. If only a full URL column exists and the id cannot be safely extracted, use a TEXT field instead.
@@ -934,9 +1235,17 @@ Contact: **[Irving Enciso](<linkedin URL>)**, Regional Operations Manager \xB7 \
934
1235
 
935
1236
  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
1237
 
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.
1238
+ ## Linking a contact's name
1239
+
1240
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
1241
+
1242
+ URL priority (first applicable wins):
1243
+
1244
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
1245
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
1246
+
1247
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
1248
+
940
1249
 
941
1250
  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
1251
 
@@ -1306,6 +1615,46 @@ WHEN NOT TO USE: the user is just reading or researching a lead without expressi
1306
1615
 
1307
1616
  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\`.
1308
1617
  `;
1618
+ var leadbay_list_campaigns = `## WHEN TO USE
1619
+
1620
+ Trigger phrases: "what campaigns do I have", "list my campaigns", "show me my active campaigns", "campaign overview", "what's in flight", "pulse on my campaigns".
1621
+
1622
+ Do NOT use for: "create a new campaign" \u2192 \`leadbay_create_campaign\`; "drill into one specific campaign's progression" \u2192 \`leadbay_campaign_progression\`.
1623
+
1624
+ Prefer when: user wants the pulse / overview view across all their campaigns. Use campaign_progression to drill into one
1625
+
1626
+ Examples that SHOULD invoke this tool:
1627
+ - "What campaigns am I running?"
1628
+ - "Show me my active campaigns and how they're doing."
1629
+ - "Quick pulse on my campaigns."
1630
+
1631
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
1632
+ - "Create a 'Q2 Push' campaign."
1633
+ - "How are leads progressing in my Limoges Tour?"
1634
+ - "Today's fresh leads."
1635
+
1636
+ ## RENDER (quick)
1637
+
1638
+ Render as a compact markdown table sorted by \`updated_at\` desc:
1639
+ | Campaign | Leads | Contacted | Meetings | Declined | Updated |
1640
+ Each row links to "View progression" via a NEXT STEPS chip that
1641
+ passes the campaign_id back into \`leadbay_campaign_progression\`.
1642
+ Suppress archived rows unless \`archived: true\` was passed.
1643
+
1644
+ ---
1645
+
1646
+ List the caller's campaigns with roll-up stats. Wraps \`GET /campaigns\` (with \`?archived=true\` to surface archived ones). Each entry is \`CampaignWithStatsPayload\`: the campaign object + \`lead_count\`, \`contact_count\`, \`contacted\` (leads with any logged outreach), \`meeting_booked\` (leads with a recorded meeting outcome), \`declined\` (leads with a recorded decline).
1647
+
1648
+ **Scope warning**: this list is filtered server-side to campaigns the calling user CREATED. Other users in the same organization with their own campaigns are not surfaced. The web UI may aggregate differently for admins; the MCP-exposed view is creator-scoped. This matters for #3630 US3 "manager-led prospecting governance" \u2014 a manager can list their own campaigns but not see a rep's. Cross-user visibility would need backend work.
1649
+
1650
+ ---
1651
+
1652
+ WHEN TO USE: the user wants the cross-campaign pulse \u2014 what cohorts are in flight, how active each is, which need attention.
1653
+
1654
+ WHEN NOT TO USE: for per-lead progression inside ONE campaign (use \`leadbay_campaign_progression\`); to create a campaign (\`leadbay_create_campaign\`); to add leads to one (\`leadbay_add_leads_to_campaign\`).
1655
+
1656
+ **Response**: \`{campaigns: CampaignWithStats[], _meta}\`. Sort by \`updated_at desc\` when rendering \u2014 recency is the manager's natural lens.
1657
+ `;
1309
1658
  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.
1310
1659
 
1311
1660
  WHEN TO USE: when the user wants to switch lens or asks "what lenses do I have".
@@ -1546,19 +1895,14 @@ One line: \`+N more contacts at this company \u2014 [see them all](leadbay_resea
1546
1895
 
1547
1896
  ## Linking a contact's name
1548
1897
 
1549
- Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
1898
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
1550
1899
 
1551
- 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.
1552
-
1553
- Otherwise fall back to a LinkedIn people-search URL:
1554
-
1555
- \`\`\`
1556
- https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1557
- \`\`\`
1900
+ URL priority (first applicable wins):
1558
1901
 
1559
- 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.
1902
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
1903
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
1560
1904
 
1561
- 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.
1905
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
1562
1906
 
1563
1907
  ## Linking the company
1564
1908
 
@@ -1752,19 +2096,14 @@ Markers: \`\u2605\` recommended, \`\u{1F48E}\` hot in web_insights key_people. C
1752
2096
 
1753
2097
  ## Linking a contact's name
1754
2098
 
1755
- Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
2099
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
1756
2100
 
1757
- 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.
2101
+ URL priority (first applicable wins):
1758
2102
 
1759
- Otherwise fall back to a LinkedIn people-search URL:
2103
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
2104
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
1760
2105
 
1761
- \`\`\`
1762
- https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1763
- \`\`\`
1764
-
1765
- 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.
1766
-
1767
- 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.
2106
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
1768
2107
 
1769
2108
  ## Linking the company
1770
2109
 
@@ -1916,25 +2255,20 @@ If \`qualification_summary.answered == 0\` or \`avg_qualification_boost\` is nul
1916
2255
 
1917
2256
  **Column 3 \u2014 Contact**
1918
2257
 
1919
- \`[Contact name](LINK) \xB7 short job title\`. See linking/contact-linkedin for LINK priority and the \xB0-flag fallback.
2258
+ \`[Contact name](LINK) \xB7 short job title\`. The \`[Contact name](LINK)\` markdown link wrapping is mandatory \u2014 never render the name as plain text. See linking/contact-linkedin for the URL priority (real profile \u2192 constructed people-search) and the \xB0-flag fallback.
1920
2259
 
1921
2260
  **Hide from the user (never include in any cell):** \`id\`, \`location.pos\`, \`location.country\` (unless city/state both missing), \`sector_id\`, \`is_hq\`, \`web_fetch_in_progress\`, \`enrichment_in_progress\`, \`highlighted_fields\`, \`custom_fields\`, \`contacts_count\` when 0, \`notes_count\` / \`epilogue_actions_count\` / \`prospecting_actions_count\` when 0, \`stale_at\`, \`deal_insights\`, \`social_presence\` booleans (except as the \xB0-flag signal), \`need_attention\` flags, any field whose value is the string \`"null"\`.
1922
2261
 
1923
2262
  ## Linking a contact's name
1924
2263
 
1925
- Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
2264
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
1926
2265
 
1927
- 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.
1928
-
1929
- Otherwise fall back to a LinkedIn people-search URL:
1930
-
1931
- \`\`\`
1932
- https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1933
- \`\`\`
2266
+ URL priority (first applicable wins):
1934
2267
 
1935
- 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.
2268
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
2269
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
1936
2270
 
1937
- 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.
2271
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
1938
2272
 
1939
2273
  ## Linking the company
1940
2274
 
@@ -2161,19 +2495,14 @@ If \`qualification[]\` is non-empty, append one collapsed line: \`"Qualification
2161
2495
 
2162
2496
  ## Linking a contact's name
2163
2497
 
2164
- Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
2498
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
2165
2499
 
2166
- 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.
2500
+ URL priority (first applicable wins):
2167
2501
 
2168
- Otherwise fall back to a LinkedIn people-search URL:
2502
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
2503
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
2169
2504
 
2170
- \`\`\`
2171
- https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
2172
- \`\`\`
2173
-
2174
- 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.
2175
-
2176
- 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.
2505
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
2177
2506
 
2178
2507
  ## Linking the company
2179
2508
 
@@ -2354,19 +2683,14 @@ If \`qualification[]\` is non-empty, append one collapsed line: \`"Qualification
2354
2683
 
2355
2684
  ## Linking a contact's name
2356
2685
 
2357
- Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
2686
+ **MANDATORY: every contact name in your output \u2014 table cells, prose, headers, "Reach <Name>" callouts \u2014 MUST be wrapped in markdown link syntax \`[Name](URL)\`. Never render a contact name as bare text. A plain-text name is a broken contact card; the underlined name is the user's primary affordance for "take me to this person's profile". No "no URL available" exception \u2014 the search URL below is always constructable from name + company.**
2358
2687
 
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
- \`\`\`
2688
+ URL priority (first applicable wins):
2366
2689
 
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.
2690
+ 1. **Real profile** \u2014 \`contact.linkedin_page\` when it's a string starting with \`https://\` (the MCP coerces the legacy literal \`"null"\` string to real null before you see it).
2691
+ 2. **Constructed people-search** \u2014 \`https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>\`. URL-encode params. Strip Inc / LLC / Corp / Ltd / GmbH / Co / S.A. / S.L. / PLC / AG / SAS / SARL suffixes from the company. Append a trailing \` \xB0\` to the rendered name ONLY when this fallback is in use AND \`social_presence.linkedin == false\`. Never append \`\xB0\` when a real \`linkedin_page\` was used.
2368
2692
 
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.
2693
+ Never link a person's name to the company's LinkedIn page (and vice versa) \u2014 the two surfaces are different and conflating them quietly degrades the workflow.
2370
2694
 
2371
2695
  ## Linking the company
2372
2696
 
@@ -2538,6 +2862,101 @@ WHEN NOT TO USE: from agent flow \u2014 use leadbay_refine_prompt, which polls f
2538
2862
 
2539
2863
  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\`.
2540
2864
  `;
2865
+ var leadbay_tour_plan = `## WHEN TO USE
2866
+
2867
+ Trigger phrases: "I'm visiting <city> in <N> days", "field tour in <city>", "plan a tour in <city>", "who should I meet in <city>", "mix of customers and prospects in <city>", "tour itinerary", "3 customers + 3 prospects + 3 new in <city>", "field sales tour".
2868
+
2869
+ Do NOT use for: "follow-ups only on the map (no new prospects mixed in)" \u2192 \`leadbay_followups_map\`; "new leads only, no geography" \u2192 \`leadbay_pull_leads\`; "research one specific account I picked" \u2192 \`leadbay_research_lead_by_id\`.
2870
+
2871
+ Prefer when: user wants both KNOWN accounts AND NEW discovery in one geographic view. If they only want one side, route to followups_map or pull_leads instead
2872
+
2873
+ Examples that SHOULD invoke this tool:
2874
+ - "I'm flying to Limoges in 4 days \u2014 give me 3 customers, 3 qualified prospects, and 3 new high-potential."
2875
+ - "Plan my tour next Tuesday in Lyon: known accounts plus discoveries."
2876
+ - "Build a mixed itinerary for Berlin \u2014 I want both follow-ups and fresh leads."
2877
+
2878
+ Examples that should NOT invoke this tool (sound similar, route elsewhere):
2879
+ - "Show me my follow-ups for the SF trip."
2880
+ - "What's new in today's batch?"
2881
+ - "Tell me about Acme Corp."
2882
+
2883
+ ## RENDER (quick)
2884
+
2885
+ Route the union of monitor_leads + discover_leads into
2886
+ \`places_map_display_v0\` \u2014 same recipe as followups_map (lat/lng
2887
+ split, full address, short notes). Tag each entry's notes with a
2888
+ mode badge: "\u2605 Customer" / "\u2605 Qualified" / "\u2726 New" (split Monitor
2889
+ by last_monitor_action; Discover = New). Below the widget: one
2890
+ intro sentence + per-group chat-prose list with LinkedIn-linked
2891
+ contact names.
2892
+
2893
+ ---
2894
+
2895
+ Build a single-call mixed-mode itinerary for a field sales tour. Combines \`leadbay_pull_followups\` (Monitor leads in the city \u2014 known accounts) with \`leadbay_pull_leads\` (Discover wishlist \u2014 new prospects, then client-side filtered by city) so the agent can answer the canonical #3630 US1 ask: *"I'm visiting Limoges in 4 days \u2014 propose 3 customers + 3 qualified prospects + 3 new high-potential discoveries."*
2896
+
2897
+ **Geo resolution** is identical to \`leadbay_followups_map\`: pass \`city\` (any admin level \u2014 city, state, country, region \u2014 the \`/geo/search\` resolver picks the best match), or a pre-resolved \`city_id\`. Ambiguous matches surface as \`status: "ambiguous_locations"\` + \`location_ambiguities[]\`; pick an id and re-call with \`city_id\`.
2898
+
2899
+ **Counts**: \`followups_count\` (default 6 \u2014 generous so the agent can split into "customers + qualified" client-side) and \`discover_count\` (default 6 after client-side geo filter). The composite over-pulls Discover (30 raw) because the wishlist endpoint has no server-side geo filter \u2014 it then filters by \`location.city/state/country/full\` substring match against the requested city. The \`discover_filter_note\` string in the response tells the agent the match ratio so it can be honest about coverage ("matched 3/30 by city/state" vs. "matched 12/30").
2900
+
2901
+ **What \`tour_plan\` does NOT do**: it doesn't persist the tour as a campaign artifact. To do that \u2014 create a "Limoges Tour \u2013 May 24" campaign and attach the selected accounts \u2014 chain into \`leadbay_create_campaign({lead_ids: [...selected_ids], name: 'Limoges Tour \u2013 <date>'})\` after the user picks. See the \`leadbay_plan_tour_in_city\` prompt for the full end-to-end orchestrator.
2902
+
2903
+ ---
2904
+
2905
+ ## RENDER \u2014 host-native map widget (REQUIRED, preferred)
2906
+
2907
+ Identical to \`leadbay_followups_map\`. Pass the union \`monitor_leads + discover_leads\` into \`places_map_display_v0\`. The mode information goes in the per-lead \`notes\` string as a **leading badge**:
2908
+
2909
+ - \`\u2605 Customer\` \u2014 Monitor lead with an existing \`last_monitor_action\` history (engaged / past interaction).
2910
+ - \`\u2605 Qualified\` \u2014 Monitor lead with a high \`ai_agent_lead_score\` or \`score\` but no recent action.
2911
+ - \`\u2726 New\` \u2014 Discover lead from \`discover_leads\`.
2912
+
2913
+ Then the rest of the notes string follows the standard recipe: ONE sentence, sector/fit + contact ask, bare phone + email auto-linkified. Example:
2914
+
2915
+ \`\`\`
2916
+ \u2726 New \u2014 Strong mid-size hardware distributor fit. Reach Marie Dupont, Sales Director: +33 5 55 12 34 56, m.dupont@example.fr.
2917
+ \`\`\`
2918
+
2919
+ Skip any lead whose \`location.pos\` is null (no lat/lng \u2192 no pin) \u2014 list them as a "+ N leads without coordinates" footer below the widget.
2920
+
2921
+ ## Chat prose AFTER the widget (where markdown DOES render)
2922
+
2923
+ Group the leads into THREE sections (Customers / Qualified Prospects / New Discoveries) and emit a short chat-prose list per group with LinkedIn-linked contact name + bare phone/email pills. This is the "Notes from Claude" companion that the carousel can't render. Mirror the \`followups_map\` recipe exactly \u2014 score callout, contact name as markdown link, \`\xB7\`-separated channel pills.
2924
+
2925
+ If the user said something like "3+3+3", honor that split. If \`followups_count\` returned fewer Customers than asked, fill from Qualified.
2926
+
2927
+ ## RENDER \u2014 fallback for hosts without \`places_map_display_v0\`
2928
+
2929
+ Emit per-lead markdown blocks grouped by mode, in the standard place-card-friendly format (\`### **Company** \xB7 City, State\`, mode badge + score, contact line, channel lines). Hosts that auto-detect addresses (Claude.ai web, cowork) build their own carousel from these blocks.
2930
+
2931
+ ## GATE \u2014 PREFER BUILT-IN HOST WIDGETS
2932
+
2933
+ 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.
2934
+
2935
+ **The Big Three** \u2014 when a tool result fits, route there:
2936
+
2937
+ | Host widget | Use when | Field map (from Leadbay payload) |
2938
+ |---|---|---|
2939
+ | \`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 |
2940
+ | \`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") |
2941
+ | \`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 |
2942
+
2943
+ 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.
2944
+
2945
+ **Rules:**
2946
+ - 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.
2947
+ - Pass identifiers (place_id, lead.id, contact_id) verbatim. Don't rewrite.
2948
+ - 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.
2949
+ - One short intro sentence in chat is enough \u2014 "Here are your 5 NYC follow-ups." Then route into the widget.
2950
+
2951
+
2952
+ ---
2953
+
2954
+ WHEN TO USE: the user signals a *mixed* tour-planning intent \u2014 they want both known accounts AND fresh discoveries on one geographic view, typically for a planned visit.
2955
+
2956
+ WHEN NOT TO USE: if the user only wants follow-ups (use \`leadbay_followups_map\`), only wants new leads (use \`leadbay_pull_leads\`), wants research on one specific account (\`leadbay_research_lead_by_id\`), or wants to persist the tour as a campaign artifact (chain into \`leadbay_create_campaign\` after this).
2957
+
2958
+ **Response envelope**: \`{city, city_id, monitor_leads, discover_leads, discover_filter_note, _meta}\` on happy path; \`{status: "ambiguous_locations", location_ambiguities, ...}\` when the passed \`city\` matched multiple admin areas.
2959
+ `;
2541
2960
  var leadbay_update_lens = `Update lens metadata (name, description, mode flags). Does NOT change the audience filter \u2014 use leadbay_update_lens_filter for that.
2542
2961
 
2543
2962
  WHEN TO USE: rename a lens or toggle \`multi_product_mode\` / \`use_hq_only\`.
@@ -6793,8 +7212,635 @@ var followupsMap = {
6793
7212
  execute: pullFollowups.execute
6794
7213
  };
6795
7214
 
6796
- // ../core/dist/composite/research-lead-by-id.js
6797
- function normalizeLinkedinPage5(v) {
7215
+ // ../core/dist/composite/tour-plan.js
7216
+ var DEFAULT_FOLLOWUPS_COUNT = 6;
7217
+ var DEFAULT_DISCOVER_COUNT = 6;
7218
+ var DISCOVER_OVER_PULL = 30;
7219
+ function cityMatches(lead, cityHint) {
7220
+ if (!cityHint)
7221
+ return true;
7222
+ const hint = cityHint.toLowerCase();
7223
+ const loc = lead?.location ?? {};
7224
+ const haystacks = [loc.city, loc.state, loc.country, loc.full].filter((v) => typeof v === "string").map((v) => v.toLowerCase());
7225
+ return haystacks.some((h) => h.includes(hint) || hint.includes(h));
7226
+ }
7227
+ var tourPlan = {
7228
+ name: "leadbay_tour_plan",
7229
+ annotations: {
7230
+ title: "Plan a mixed-mode tour itinerary (known + fresh leads)",
7231
+ readOnlyHint: true,
7232
+ destructiveHint: false,
7233
+ idempotentHint: true,
7234
+ openWorldHint: true
7235
+ },
7236
+ description: leadbay_tour_plan,
7237
+ inputSchema: {
7238
+ type: "object",
7239
+ properties: {
7240
+ city: {
7241
+ type: "string",
7242
+ description: "Free-text city or region (e.g. 'Limoges', 'Bay Area'). Resolved via the same /geo/search the followups_map uses. Ambiguous matches surface as `status: ambiguous_locations` with location_ambiguities[]; pick a location id and re-call with city_id."
7243
+ },
7244
+ city_id: {
7245
+ type: "string",
7246
+ description: "Pre-resolved admin_area id (numeric string). Bypasses the resolver."
7247
+ },
7248
+ followups_count: {
7249
+ type: "number",
7250
+ description: `Top-N follow-up (Monitor) leads to return. Default ${DEFAULT_FOLLOWUPS_COUNT}.`
7251
+ },
7252
+ discover_count: {
7253
+ type: "number",
7254
+ description: `Top-N Discover leads (active lens wishlist) to return after client-side city filter. Default ${DEFAULT_DISCOVER_COUNT}.`
7255
+ }
7256
+ },
7257
+ additionalProperties: false
7258
+ },
7259
+ outputSchema: {
7260
+ type: "object",
7261
+ properties: {
7262
+ city: { type: ["string", "null"] },
7263
+ city_id: { type: ["string", "null"] },
7264
+ monitor_leads: {
7265
+ type: "array",
7266
+ description: "Follow-up (Monitor) leads in the requested city, sorted by AI / firmographic score. Each lead carries the same shape as pull_followups.",
7267
+ items: { type: "object" }
7268
+ },
7269
+ discover_leads: {
7270
+ type: "array",
7271
+ description: "Fresh Discover leads from the active lens, filtered client-side to match the city. Pulls a larger candidate set internally to compensate for the missing server-side geo filter.",
7272
+ items: { type: "object" }
7273
+ },
7274
+ discover_filter_note: {
7275
+ type: "string",
7276
+ description: "Human-readable summary of the client-side geo filter applied to Discover leads (e.g. 'matched 3/30 by city/state')."
7277
+ },
7278
+ status: {
7279
+ type: "string",
7280
+ description: "'ambiguous_locations' when the passed `city` matched multiple admin areas \u2014 pick an id from location_ambiguities and re-call with city_id."
7281
+ },
7282
+ location_ambiguities: {
7283
+ type: "array",
7284
+ items: { type: "object" }
7285
+ },
7286
+ _meta: {
7287
+ type: "object",
7288
+ properties: {
7289
+ region: { type: "string" },
7290
+ latency_ms: { type: ["number", "null"] }
7291
+ }
7292
+ }
7293
+ },
7294
+ required: ["monitor_leads", "discover_leads"]
7295
+ },
7296
+ execute: async (client, params, ctx) => {
7297
+ const followupsCount = params.followups_count ?? DEFAULT_FOLLOWUPS_COUNT;
7298
+ const discoverCount = params.discover_count ?? DEFAULT_DISCOVER_COUNT;
7299
+ const [followupsResult, leadsResult] = await Promise.allSettled([
7300
+ pullFollowups.execute(client, {
7301
+ city: params.city,
7302
+ city_id: params.city_id,
7303
+ count: followupsCount
7304
+ }, ctx),
7305
+ pullLeads.execute(client, { count: DISCOVER_OVER_PULL }, ctx)
7306
+ ]);
7307
+ if (followupsResult.status === "fulfilled") {
7308
+ const r = followupsResult.value;
7309
+ if (r?.status === "ambiguous_locations") {
7310
+ return {
7311
+ status: "ambiguous_locations",
7312
+ location_ambiguities: r.location_ambiguities,
7313
+ monitor_leads: [],
7314
+ discover_leads: [],
7315
+ discover_filter_note: "City was ambiguous; pick an id and re-call to proceed.",
7316
+ city: params.city ?? null,
7317
+ city_id: params.city_id ?? null,
7318
+ _meta: {
7319
+ region: client.region,
7320
+ latency_ms: client.lastMeta?.latency_ms ?? null
7321
+ }
7322
+ };
7323
+ }
7324
+ }
7325
+ const monitorLeads = followupsResult.status === "fulfilled" ? followupsResult.value?.leads ?? [] : [];
7326
+ if (followupsResult.status === "rejected") {
7327
+ ctx?.logger?.warn?.(`tour_plan: pull_followups failed: ${followupsResult.reason?.message ?? followupsResult.reason}`);
7328
+ }
7329
+ const rawDiscover = leadsResult.status === "fulfilled" ? leadsResult.value?.leads ?? [] : [];
7330
+ if (leadsResult.status === "rejected") {
7331
+ ctx?.logger?.warn?.(`tour_plan: pull_leads failed: ${leadsResult.reason?.message ?? leadsResult.reason}`);
7332
+ }
7333
+ const filtered = rawDiscover.filter((l) => cityMatches(l, params.city));
7334
+ const discoverLeads2 = filtered.slice(0, discoverCount);
7335
+ const filterNote = params.city ? `Matched ${filtered.length}/${rawDiscover.length} Discover leads to '${params.city}'; returning top ${discoverLeads2.length}.` : `No city filter applied; returning top ${discoverLeads2.length} Discover leads.`;
7336
+ return {
7337
+ city: params.city ?? null,
7338
+ city_id: params.city_id ?? null,
7339
+ monitor_leads: monitorLeads,
7340
+ discover_leads: discoverLeads2,
7341
+ discover_filter_note: filterNote,
7342
+ _meta: {
7343
+ region: client.region,
7344
+ latency_ms: client.lastMeta?.latency_ms ?? null
7345
+ }
7346
+ };
7347
+ }
7348
+ };
7349
+
7350
+ // ../core/dist/composite/create-campaign.js
7351
+ var createCampaign = {
7352
+ name: "leadbay_create_campaign",
7353
+ annotations: {
7354
+ title: "Create a named campaign (optionally seeded with leads)",
7355
+ readOnlyHint: false,
7356
+ destructiveHint: true,
7357
+ idempotentHint: false,
7358
+ openWorldHint: true
7359
+ },
7360
+ description: leadbay_create_campaign,
7361
+ optional: true,
7362
+ // gated behind LEADBAY_MCP_WRITE=1 in MCP
7363
+ inputSchema: {
7364
+ type: "object",
7365
+ properties: {
7366
+ name: {
7367
+ type: "string",
7368
+ description: "Campaign display name (max 255 chars). Omit to let the backend AI-generate one from the seed lead_ids. If lead_ids is empty AND name is omitted, the backend assigns a default."
7369
+ },
7370
+ lead_ids: {
7371
+ type: "array",
7372
+ description: "Lead UUIDs to attach at creation. Empty array (default) creates an empty campaign \u2014 add leads later via leadbay_add_leads_to_campaign. Non-empty seed enables AI name suggestion.",
7373
+ items: { type: "string" }
7374
+ }
7375
+ },
7376
+ additionalProperties: false
7377
+ },
7378
+ outputSchema: {
7379
+ type: "object",
7380
+ properties: {
7381
+ id: { type: "string", description: "Campaign UUID." },
7382
+ name: { type: "string" },
7383
+ ai_generated_name: { type: ["string", "null"] },
7384
+ ai_name_count: { type: "number" },
7385
+ archived: { type: "boolean" },
7386
+ created_by: { type: "string" },
7387
+ created_at: { type: "string" },
7388
+ updated_at: { type: "string" },
7389
+ last_accessed_at: { type: "string" }
7390
+ },
7391
+ required: ["id", "name", "created_at"]
7392
+ },
7393
+ execute: async (client, params) => {
7394
+ const body = {
7395
+ lead_ids: params.lead_ids ?? []
7396
+ };
7397
+ if (params.name && params.name.trim().length > 0) {
7398
+ body.name = params.name.slice(0, 255);
7399
+ }
7400
+ const result = await client.request("POST", `/campaigns`, body);
7401
+ return result;
7402
+ }
7403
+ };
7404
+
7405
+ // ../core/dist/composite/add-leads-to-campaign.js
7406
+ var addLeadsToCampaign = {
7407
+ name: "leadbay_add_leads_to_campaign",
7408
+ annotations: {
7409
+ title: "Add leads to an existing campaign",
7410
+ readOnlyHint: false,
7411
+ destructiveHint: true,
7412
+ idempotentHint: true,
7413
+ openWorldHint: true
7414
+ },
7415
+ description: leadbay_add_leads_to_campaign,
7416
+ optional: true,
7417
+ inputSchema: {
7418
+ type: "object",
7419
+ properties: {
7420
+ campaign_id: {
7421
+ type: "string",
7422
+ description: "Campaign UUID (from leadbay_create_campaign or leadbay_list_campaigns)."
7423
+ },
7424
+ lead_ids: {
7425
+ type: "array",
7426
+ description: "Lead UUIDs to add. Backend rejects unknown lead UUIDs with 404 \u2014 pass UUIDs sourced from pull_leads / pull_followups / tour_plan / research.",
7427
+ items: { type: "string" },
7428
+ minItems: 1
7429
+ }
7430
+ },
7431
+ required: ["campaign_id", "lead_ids"],
7432
+ additionalProperties: false
7433
+ },
7434
+ outputSchema: {
7435
+ type: "object",
7436
+ properties: {
7437
+ added: { type: "number", description: "Leads newly attached." },
7438
+ already_present: { type: "number", description: "Leads that were already in this campaign \u2014 no-op." }
7439
+ },
7440
+ required: ["added", "already_present"]
7441
+ },
7442
+ execute: async (client, params) => {
7443
+ if (!params.lead_ids || params.lead_ids.length === 0) {
7444
+ throw client.makeError("INVALID_PARAMS", "lead_ids must be a non-empty array", "Pass at least one lead UUID to add. To create an empty campaign, use leadbay_create_campaign with lead_ids: [].");
7445
+ }
7446
+ const result = await client.request("POST", `/campaigns/${params.campaign_id}/leads`, { lead_ids: params.lead_ids });
7447
+ return result;
7448
+ }
7449
+ };
7450
+
7451
+ // ../core/dist/composite/list-campaigns.js
7452
+ var listCampaigns = {
7453
+ name: "leadbay_list_campaigns",
7454
+ annotations: {
7455
+ title: "List your campaigns (with roll-up stats)",
7456
+ readOnlyHint: true,
7457
+ destructiveHint: false,
7458
+ idempotentHint: true,
7459
+ openWorldHint: true
7460
+ },
7461
+ description: leadbay_list_campaigns,
7462
+ inputSchema: {
7463
+ type: "object",
7464
+ properties: {
7465
+ archived: {
7466
+ type: "boolean",
7467
+ description: "Include archived campaigns only. Default false (active only)."
7468
+ }
7469
+ },
7470
+ additionalProperties: false
7471
+ },
7472
+ outputSchema: {
7473
+ type: "object",
7474
+ properties: {
7475
+ campaigns: {
7476
+ type: "array",
7477
+ description: "Each entry is {campaign, lead_count, contact_count, contacted, meeting_booked, declined}. `contacted` = leads with at least one logged outreach; `meeting_booked` = leads with a recorded meeting outcome; `declined` = leads with a recorded decline outcome.",
7478
+ items: { type: "object" }
7479
+ },
7480
+ _meta: {
7481
+ type: "object",
7482
+ properties: {
7483
+ region: { type: "string" },
7484
+ latency_ms: { type: ["number", "null"] }
7485
+ }
7486
+ }
7487
+ },
7488
+ required: ["campaigns"]
7489
+ },
7490
+ execute: async (client, params) => {
7491
+ const archived = params.archived ?? false;
7492
+ const qs = archived ? "?archived=true" : "";
7493
+ const campaigns = await client.request("GET", `/campaigns${qs}`);
7494
+ return {
7495
+ campaigns,
7496
+ _meta: {
7497
+ region: client.region,
7498
+ latency_ms: client.lastMeta?.latency_ms ?? null
7499
+ }
7500
+ };
7501
+ }
7502
+ };
7503
+
7504
+ // ../core/dist/composite/campaign-progression.js
7505
+ function hasOutreachSignal(progress) {
7506
+ if (!progress)
7507
+ return false;
7508
+ return Boolean(progress.headline || (progress.in_progress ?? 0) > 0 || (progress.declined ?? 0) > 0);
7509
+ }
7510
+ var campaignProgression = {
7511
+ name: "leadbay_campaign_progression",
7512
+ annotations: {
7513
+ title: "Read per-lead progression inside a campaign",
7514
+ readOnlyHint: true,
7515
+ destructiveHint: false,
7516
+ idempotentHint: true,
7517
+ openWorldHint: true
7518
+ },
7519
+ description: leadbay_campaign_progression,
7520
+ inputSchema: {
7521
+ type: "object",
7522
+ properties: {
7523
+ campaign_id: {
7524
+ type: "string",
7525
+ description: "Campaign UUID (from leadbay_create_campaign or leadbay_list_campaigns)."
7526
+ },
7527
+ count: { type: "number", description: "Leads per page (default 50, server-capped)." },
7528
+ page: { type: "number", description: "0-indexed page (default 0)." }
7529
+ },
7530
+ required: ["campaign_id"],
7531
+ additionalProperties: false
7532
+ },
7533
+ outputSchema: {
7534
+ type: "object",
7535
+ properties: {
7536
+ items: {
7537
+ type: "array",
7538
+ description: "Per-lead progression rows: {lead, progress: {total_contacts, in_progress, declined, headline}, affiliation: {own_campaigns, other_users_campaign_count}}. `headline` is the most recent interaction type (e.g. CONTACTED, MEETING_BOOKED, DECLINED).",
7539
+ items: { type: "object" }
7540
+ },
7541
+ pagination: {
7542
+ type: "object",
7543
+ properties: {
7544
+ page: { type: "number" },
7545
+ pages: { type: "number" },
7546
+ total: { type: "number" }
7547
+ }
7548
+ },
7549
+ summary: {
7550
+ type: "object",
7551
+ description: "Roll-up across the current page: how many leads have any outreach, how many converted to meetings, how many were declined.",
7552
+ properties: {
7553
+ page_size: { type: "number" },
7554
+ contacted: { type: "number" },
7555
+ in_progress: { type: "number" },
7556
+ declined: { type: "number" }
7557
+ }
7558
+ },
7559
+ _meta: {
7560
+ type: "object",
7561
+ properties: {
7562
+ region: { type: "string" },
7563
+ latency_ms: { type: ["number", "null"] }
7564
+ }
7565
+ }
7566
+ },
7567
+ required: ["items", "pagination", "summary"]
7568
+ },
7569
+ execute: async (client, params) => {
7570
+ const count = params.count ?? 50;
7571
+ const page = params.page ?? 0;
7572
+ const result = await client.request("GET", `/campaigns/${params.campaign_id}/leads?count=${count}&page=${page}`);
7573
+ let contacted = 0;
7574
+ let inProgress = 0;
7575
+ let declined = 0;
7576
+ for (const row of result.items) {
7577
+ const p = row.progress;
7578
+ if (hasOutreachSignal(p))
7579
+ contacted++;
7580
+ if ((p?.in_progress ?? 0) > 0)
7581
+ inProgress++;
7582
+ if ((p?.declined ?? 0) > 0)
7583
+ declined++;
7584
+ }
7585
+ return {
7586
+ items: result.items,
7587
+ pagination: result.pagination,
7588
+ summary: {
7589
+ page_size: result.items.length,
7590
+ contacted,
7591
+ in_progress: inProgress,
7592
+ declined
7593
+ },
7594
+ _meta: {
7595
+ region: client.region,
7596
+ latency_ms: client.lastMeta?.latency_ms ?? null
7597
+ }
7598
+ };
7599
+ }
7600
+ };
7601
+
7602
+ // ../core/dist/composite/campaign-call-sheet.js
7603
+ function hasOutreachSignal2(progress) {
7604
+ if (!progress)
7605
+ return false;
7606
+ return Boolean(progress.headline || (progress.in_progress ?? 0) > 0 || (progress.declined ?? 0) > 0);
7607
+ }
7608
+ function normalizeLinkedin(v) {
7609
+ if (typeof v !== "string")
7610
+ return null;
7611
+ const t = v.trim();
7612
+ if (!t || t.toLowerCase() === "null")
7613
+ return null;
7614
+ return t;
7615
+ }
7616
+ function constructLinkedinFallback(first, last, company) {
7617
+ if (!first || !last)
7618
+ return null;
7619
+ const cleanedCompany = (company ?? "").replace(/\s+(Inc|LLC|Corp|GmbH|Ltd|Co|S\.A\.|S\.L\.|PLC|AG|SAS|SARL)\.?$/gi, "").trim();
7620
+ const keywords = encodeURIComponent([first, last, cleanedCompany].filter(Boolean).join(" "));
7621
+ return `https://www.linkedin.com/search/results/people/?keywords=${keywords}`;
7622
+ }
7623
+ var campaignCallSheet = {
7624
+ name: "leadbay_campaign_call_sheet",
7625
+ annotations: {
7626
+ title: "Cold-calling cheat sheet for a campaign",
7627
+ readOnlyHint: true,
7628
+ destructiveHint: false,
7629
+ idempotentHint: true,
7630
+ openWorldHint: true
7631
+ },
7632
+ description: leadbay_campaign_call_sheet,
7633
+ inputSchema: {
7634
+ type: "object",
7635
+ properties: {
7636
+ campaign_id: {
7637
+ type: "string",
7638
+ description: "Campaign UUID (from leadbay_create_campaign or leadbay_list_campaigns)."
7639
+ },
7640
+ count: {
7641
+ type: "number",
7642
+ description: "Leads per page for the underlying /campaigns/{id}/leads fetch (default 50). The contacts endpoint is not paginated; the composite always fetches all contacts and aligns them with the leads page."
7643
+ },
7644
+ page: {
7645
+ type: "number",
7646
+ description: "0-indexed page (default 0)."
7647
+ }
7648
+ },
7649
+ required: ["campaign_id"],
7650
+ additionalProperties: false
7651
+ },
7652
+ outputSchema: {
7653
+ type: "object",
7654
+ properties: {
7655
+ campaign_id: { type: "string" },
7656
+ leads: {
7657
+ type: "array",
7658
+ description: "Per-lead call-ready blocks sorted by score desc. Each block: {lead_id, lead_name, score, ai_agent_lead_score, location, website, next_step, approach_angle, worth_pursuing, progress, affiliation, contacts: [{first_name, last_name, full_name, phone_number, phone_tel_url, email, mailto_url, linkedin_url, linkedin_url_source, job_title, recommended, pinned, recent_notes}]}.",
7659
+ items: { type: "object" }
7660
+ },
7661
+ map_locations: {
7662
+ type: "array",
7663
+ description: "Ready-to-pass entries for `places_map_display_v0` when the user wants a geographic call route: {name, address, latitude, longitude, notes}. One entry per lead that has a valid pos[]. Skip leads without coordinates.",
7664
+ items: { type: "object" }
7665
+ },
7666
+ summary: {
7667
+ type: "object",
7668
+ description: "Roll-up: total_leads, total_contacts, leads_with_phone, leads_with_email, leads_with_coords, leads_without_contacts, leads_already_contacted (headline/in_progress/declined outreach signal present).",
7669
+ properties: {
7670
+ total_leads: { type: "number" },
7671
+ total_contacts: { type: "number" },
7672
+ leads_with_phone: { type: "number" },
7673
+ leads_with_email: { type: "number" },
7674
+ leads_with_coords: { type: "number" },
7675
+ leads_without_contacts: { type: "number" },
7676
+ leads_already_contacted: { type: "number" }
7677
+ }
7678
+ },
7679
+ readiness: {
7680
+ type: "object",
7681
+ description: "Pre-computed booleans the orchestrator prompt uses to decide which session modes to OFFER. ready_for_calling (phone coverage \u226560%), ready_for_emailing (email coverage \u226560%), needs_enrichment (\u226530% no-contact leads OR both phone+email coverage <40%), travel_friendly (\u22655 geocoded leads AND coord coverage \u226560%).",
7682
+ properties: {
7683
+ ready_for_calling: { type: "boolean" },
7684
+ ready_for_emailing: { type: "boolean" },
7685
+ needs_enrichment: { type: "boolean" },
7686
+ travel_friendly: { type: "boolean" }
7687
+ }
7688
+ },
7689
+ pagination: {
7690
+ type: "object",
7691
+ properties: {
7692
+ page: { type: "number" },
7693
+ pages: { type: "number" },
7694
+ total: { type: "number" }
7695
+ }
7696
+ },
7697
+ _meta: {
7698
+ type: "object",
7699
+ properties: {
7700
+ region: { type: "string" },
7701
+ latency_ms: { type: ["number", "null"] }
7702
+ }
7703
+ }
7704
+ },
7705
+ required: ["campaign_id", "leads", "summary"]
7706
+ },
7707
+ execute: async (client, params) => {
7708
+ const count = params.count ?? 50;
7709
+ const page = params.page ?? 0;
7710
+ const [contactsRes, leadsRes] = await Promise.all([
7711
+ client.request("GET", `/campaigns/${params.campaign_id}/contacts`),
7712
+ client.request("GET", `/campaigns/${params.campaign_id}/leads?count=${count}&page=${page}`)
7713
+ ]);
7714
+ const contactsByLeadId = /* @__PURE__ */ new Map();
7715
+ for (const row of contactsRes) {
7716
+ contactsByLeadId.set(row.lead_id, row);
7717
+ }
7718
+ const blocks = leadsRes.items.map((row) => {
7719
+ const lead = row.lead;
7720
+ const contactsRow = contactsByLeadId.get(lead.id);
7721
+ const contacts = (contactsRow?.contacts ?? []).map((c) => {
7722
+ const linkedin = normalizeLinkedin(c.contact.linkedin_page);
7723
+ const fallbackLi = linkedin ? null : constructLinkedinFallback(c.contact.first_name, c.contact.last_name, lead.name);
7724
+ const phone = (c.contact.phone_number ?? "").trim() || null;
7725
+ const telUrl = phone ? `tel:${phone.replace(/[^\d+]/g, "")}` : null;
7726
+ const email = (c.contact.email ?? "").trim() || null;
7727
+ const mailtoUrl = email ? `mailto:${email}` : null;
7728
+ const fullName = [c.contact.first_name, c.contact.last_name].filter(Boolean).join(" ").trim();
7729
+ return {
7730
+ id: c.contact.id,
7731
+ first_name: c.contact.first_name ?? null,
7732
+ last_name: c.contact.last_name ?? null,
7733
+ full_name: fullName || null,
7734
+ job_title: c.contact.job_title ?? null,
7735
+ phone_number: phone,
7736
+ phone_tel_url: telUrl,
7737
+ email,
7738
+ mailto_url: mailtoUrl,
7739
+ linkedin_url: linkedin ?? fallbackLi,
7740
+ linkedin_url_source: linkedin ? "leadbay" : fallbackLi ? "constructed" : null,
7741
+ recommended: c.contact.recommended ?? false,
7742
+ pinned: c.contact.pinned ?? false,
7743
+ pinned_by_ai: c.contact.pinned_by_ai ?? false,
7744
+ recent_notes: c.recent_notes ?? []
7745
+ };
7746
+ });
7747
+ contacts.sort((a, b) => {
7748
+ const aRank = (a.pinned_by_ai ? 4 : 0) + (a.recommended ? 2 : 0) + (a.phone_number ? 1 : 0);
7749
+ const bRank = (b.pinned_by_ai ? 4 : 0) + (b.recommended ? 2 : 0) + (b.phone_number ? 1 : 0);
7750
+ return bRank - aRank;
7751
+ });
7752
+ const splitSummary = lead.split_ai_summary ?? {};
7753
+ const headline = row.progress?.headline ?? null;
7754
+ return {
7755
+ lead_id: lead.id,
7756
+ lead_name: lead.name,
7757
+ score: lead.score ?? null,
7758
+ ai_agent_lead_score: lead.ai_agent_lead_score ?? null,
7759
+ website: lead.website ?? null,
7760
+ location: lead.location ?? null,
7761
+ company_phone_numbers: lead.phone_numbers ?? [],
7762
+ next_step: splitSummary.next_step ?? null,
7763
+ approach_angle: splitSummary.approach_angle ?? null,
7764
+ worth_pursuing: splitSummary.worth_pursuing ?? null,
7765
+ short_description: lead.short_description ?? null,
7766
+ last_action_headline: headline,
7767
+ progress: row.progress ?? null,
7768
+ affiliation: row.affiliation ?? null,
7769
+ contacts
7770
+ };
7771
+ });
7772
+ blocks.sort((a, b) => {
7773
+ const aScore = a.ai_agent_lead_score ?? a.score ?? 0;
7774
+ const bScore = b.ai_agent_lead_score ?? b.score ?? 0;
7775
+ return bScore - aScore;
7776
+ });
7777
+ const mapLocations = blocks.filter((b) => {
7778
+ const pos = b.location?.pos;
7779
+ return Array.isArray(pos) && pos.length === 2 && pos.every((n) => typeof n === "number");
7780
+ }).map((b) => {
7781
+ const topContact = b.contacts[0];
7782
+ const angle = b.next_step ?? b.approach_angle ?? "Worth pursuing";
7783
+ let notes;
7784
+ if (topContact?.phone_number && topContact?.full_name) {
7785
+ notes = `\u2605 ${angle} \u2014 call ${topContact.full_name}, \u260E ${topContact.phone_number}.`;
7786
+ } else if (topContact?.email && topContact?.full_name) {
7787
+ notes = `\u2605 ${angle} \u2014 email ${topContact.full_name} at ${topContact.email}.`;
7788
+ } else if (topContact?.full_name) {
7789
+ notes = `\u2605 ${angle} \u2014 reach ${topContact.full_name} (no channel enriched yet).`;
7790
+ } else {
7791
+ notes = `\u2605 ${angle} \u2014 enrich titles to surface a contact.`;
7792
+ }
7793
+ return {
7794
+ name: b.lead_name,
7795
+ address: b.location?.full ?? [b.location?.city, b.location?.state, b.location?.country].filter(Boolean).join(", "),
7796
+ latitude: b.location.pos[0],
7797
+ longitude: b.location.pos[1],
7798
+ notes: notes.slice(0, 280)
7799
+ // place-card notes truncate visibly past ~30 words
7800
+ };
7801
+ });
7802
+ const totalContacts = blocks.reduce((acc, b) => acc + b.contacts.length, 0);
7803
+ const leadsWithPhone = blocks.filter((b) => b.contacts.some((c) => c.phone_number) || b.company_phone_numbers.length > 0).length;
7804
+ const leadsWithEmail = blocks.filter((b) => b.contacts.some((c) => c.email)).length;
7805
+ const leadsWithoutContacts = blocks.filter((b) => b.contacts.length === 0).length;
7806
+ const leadsAlreadyContacted = blocks.filter((b) => hasOutreachSignal2(b.progress)).length;
7807
+ const leadsWithCoords = blocks.filter((b) => Array.isArray(b.location?.pos) && b.location.pos.length === 2 && b.location.pos.every((n) => typeof n === "number")).length;
7808
+ const total = blocks.length;
7809
+ const phoneRatio = total > 0 ? leadsWithPhone / total : 0;
7810
+ const emailRatio = total > 0 ? leadsWithEmail / total : 0;
7811
+ const coordRatio = total > 0 ? leadsWithCoords / total : 0;
7812
+ const noContactRatio = total > 0 ? leadsWithoutContacts / total : 0;
7813
+ const readiness = {
7814
+ ready_for_calling: phoneRatio >= 0.6,
7815
+ ready_for_emailing: emailRatio >= 0.6,
7816
+ needs_enrichment: noContactRatio >= 0.3 || phoneRatio < 0.4 && emailRatio < 0.4,
7817
+ travel_friendly: leadsWithCoords >= 5 && coordRatio >= 0.6
7818
+ };
7819
+ return {
7820
+ campaign_id: params.campaign_id,
7821
+ leads: blocks,
7822
+ map_locations: mapLocations,
7823
+ summary: {
7824
+ total_leads: blocks.length,
7825
+ total_contacts: totalContacts,
7826
+ leads_with_phone: leadsWithPhone,
7827
+ leads_with_email: leadsWithEmail,
7828
+ leads_with_coords: leadsWithCoords,
7829
+ leads_without_contacts: leadsWithoutContacts,
7830
+ leads_already_contacted: leadsAlreadyContacted
7831
+ },
7832
+ readiness,
7833
+ pagination: leadsRes.pagination,
7834
+ _meta: {
7835
+ region: client.region,
7836
+ latency_ms: client.lastMeta?.latency_ms ?? null
7837
+ }
7838
+ };
7839
+ }
7840
+ };
7841
+
7842
+ // ../core/dist/composite/research-lead-by-id.js
7843
+ function normalizeLinkedinPage5(v) {
6798
7844
  if (v == null)
6799
7845
  return null;
6800
7846
  if (typeof v !== "string")
@@ -7601,6 +8647,27 @@ var accountStatus = {
7601
8647
  _meta: {
7602
8648
  type: "object",
7603
8649
  properties: { region: { type: "string" } }
8650
+ },
8651
+ // Auto-update block. Populated by the MCP server wrapper (NOT this
8652
+ // composite) when a newer release is published on GitHub AND the
8653
+ // user hasn't suppressed it. When present, the agent should prompt
8654
+ // the user via ask_user_input_v0 with three options and route the
8655
+ // chosen action through leadbay_acknowledge_update.
8656
+ update_available: {
8657
+ type: ["object", "null"],
8658
+ properties: {
8659
+ current_version: { type: "string" },
8660
+ latest_version: { type: "string" },
8661
+ mcpb_url: {
8662
+ type: "string",
8663
+ description: "Direct download URL for the .mcpb installer asset."
8664
+ },
8665
+ release_url: {
8666
+ type: "string",
8667
+ description: "GitHub release page (changelog)."
8668
+ }
8669
+ },
8670
+ required: ["current_version", "latest_version", "mcpb_url", "release_url"]
7604
8671
  }
7605
8672
  },
7606
8673
  required: ["user", "organization"]
@@ -11416,6 +12483,10 @@ var compositeReadTools = [
11416
12483
  pullLeads,
11417
12484
  pullFollowups,
11418
12485
  followupsMap,
12486
+ tourPlan,
12487
+ listCampaigns,
12488
+ campaignProgression,
12489
+ campaignCallSheet,
11419
12490
  researchLeadById,
11420
12491
  researchLeadByNameFuzzy,
11421
12492
  recallOrderedTitles,
@@ -11456,7 +12527,11 @@ var compositeWriteTools = [
11456
12527
  // likeLead/dislikeLead are granular-shaped but should always be available
11457
12528
  // to the agent without requiring LEADBAY_MCP_ADVANCED=1.
11458
12529
  likeLead,
11459
- dislikeLead
12530
+ dislikeLead,
12531
+ // Campaign write composites — persist a hand-picked cohort of leads.
12532
+ // Backend POST endpoints; gated behind LEADBAY_MCP_WRITE=1 in MCP.
12533
+ createCampaign,
12534
+ addLeadsToCampaign
11460
12535
  ];
11461
12536
  var compositeTools = [
11462
12537
  ...compositeReadTools,