@leadbay/mcp 0.7.1 → 0.9.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.
@@ -571,6 +571,8 @@ WHEN NOT TO USE: as a substitute for leadbay_research_lead \u2014 that already i
571
571
  `;
572
572
  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
573
 
574
+ **Default to \`wait_for_completion:false\`** for any \`count > 5\` or when chained inside a multi-phase workflow \u2014 the blocking default can hit the MCP per-call timeout and surface as \`"Request timed out"\` even when the server is still working fine. The async pattern (capture \`qualify_id\`, poll \`leadbay_qualify_status\` every ~10s) is timeout-proof. Reserve the blocking form for tiny single-digit counts in interactive use.
575
+
574
576
  Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads below the top ~10 are NOT worse \u2014 the system is saving resources. This tool is how the agent spends more resources to go deeper on promising-looking leads the user hasn't had time to surface yet.
575
577
 
576
578
  WHEN TO USE: when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty \`qualification_summary\`.
@@ -578,6 +580,43 @@ WHEN TO USE: when the user wants more qualified leads than what's currently show
578
580
  WHEN NOT TO USE: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).
579
581
 
580
582
  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\`.
583
+
584
+
585
+ ---
586
+
587
+ ## Status / scalar \u2014 single-sentence shape
588
+
589
+ The response is a status confirmation or scalar \u2014 render exactly one sentence inline. Do NOT emit a card or a table. Do NOT enumerate the affected records (that's the next tool's job).
590
+
591
+ Template patterns to follow:
592
+
593
+ - Job kicked off \u2192 \`"\u2713 <Verb> N <noun(s)> \u2014 typically ~M minutes. I'll refresh when it's done."\`
594
+ - No work needed \u2192 \`"All N <noun(s)> already <state> \u2014 no work to do."\`
595
+ - Long-running \u2192 \`"\u23F3 <Verb> still running \u2014 N% complete; check back in ~M minutes."\`
596
+ - Failure \u2192 \`"\u26A0 <Verb> failed: <error>. <recovery hint>"\`
597
+
598
+ After the status line, propose the obvious refresh / progress-check / recovery action in the NEXT STEPS block. Never expand the status into a card.
599
+
600
+
601
+ Specifically for bulk qualify:
602
+
603
+ - Kicked off async \u2192 \`"\u2713 Qualifying N lead(s) (qualify_id <id>) \u2014 typically ~M minutes. I'll refresh your leads view when it's done."\`
604
+ - Blocking call returned with answers \u2192 \`"\u2713 Qualified N lead(s). Refresh your leads to see the new \u2756 caps."\`
605
+ - Already-qualified short-circuit \u2192 \`"All N leads are already qualified \u2014 no work to do."\`
606
+ - 429 mid-fanout \u2192 \`"\u26A0 Rate-limited after launching M of N \u2014 already-launched leads will complete; re-call later for the rest."\`
607
+
608
+ Do not enumerate the affected leads \u2014 that's the job of \`leadbay_pull_leads\`.
609
+
610
+ ---
611
+
612
+ ## NEXT STEPS \u2014 after kicking off bulk qualification
613
+
614
+ Exactly two offers \u2014 keep it terse, this is a status tool:
615
+
616
+ | Observation | Suggest | Calls |
617
+ |--------------------------------------|-----------------------------------------------|--------------------------------|
618
+ | Qualification kicked off (async) | "Check progress in ~30s" | leadbay_qualify_status |
619
+ | Job is done / blocking call returned | "Refresh leads view \u2014 the new qualifications should be on the top" | leadbay_pull_leads(lensId = pinned) |
581
620
  `;
582
621
  var leadbay_clear_selection = `Clear the user's transient selection.
583
622
 
@@ -759,6 +798,38 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
759
798
 
760
799
 
761
800
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role; active billing. Imported leads are NOT auto-promoted to the Monitor view; lens-scoring threshold decides.
801
+
802
+ ---
803
+
804
+ ## RENDERING \u2014 import result summary (single-record, terse)
805
+
806
+ The response carries either a completed result or an async handle. Render a brief summary; do NOT enumerate every imported lead.
807
+
808
+ **Header \u2014 single line, choose by status:**
809
+
810
+ - Completed: \`"\u2713 Import complete \u2014 N leads imported \xB7 M failed \xB7 P resolved-with-ambiguity"\`
811
+ - Running: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
812
+ - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
813
+
814
+ **When failures or ambiguous rows are non-empty**, follow the header with a small bulleted list (\u2264 5 items): \`<row identifier or domain> \xB7 <reason>\`. Then \`"*+N more \u2014 leadbay_import_status for full detail*"\`.
815
+
816
+ **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
+
818
+ Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead\` in NEXT STEPS.
819
+
820
+
821
+ ---
822
+
823
+ ## NEXT STEPS \u2014 after an import
824
+
825
+ | Observation | Suggest | Calls |
826
+ |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
827
+ | Status: running | "Check progress" | leadbay_import_status(handle_id) |
828
+ | Status: complete, imports succeeded | "Run AI qualification on the imported leads" | leadbay_bulk_qualify_leads([leadIds]) \u2014 or use leadbay_import_and_qualify next time |
829
+ | Ambiguous / unresolved rows present | "Resolve the ambiguous rows" | leadbay_resolve_import_rows(records, identity_mappings)|
830
+ | Failed rows from bad mappings | "Check the org's mappable fields and remap" | leadbay_list_mappable_fields |
831
+ | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
832
+ | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
762
833
  `;
763
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.
764
835
 
@@ -774,12 +845,76 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
774
845
 
775
846
 
776
847
  Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the Leadbay account; active billing.
848
+
849
+ ---
850
+
851
+ ## RENDERING \u2014 import result summary (single-record, terse)
852
+
853
+ The response carries either a completed result or an async handle. Render a brief summary; do NOT enumerate every imported lead.
854
+
855
+ **Header \u2014 single line, choose by status:**
856
+
857
+ - Completed: \`"\u2713 Import complete \u2014 N leads imported \xB7 M failed \xB7 P resolved-with-ambiguity"\`
858
+ - Running: \`"\u23F3 Import running \u2014 handle_id <id>; poll leadbay_import_status"\`
859
+ - Pending qualification (\`leadbay_import_and_qualify\`): \`"\u2713 Imported N leads \xB7 qualifying M of them \u2014 qualify_id <id>"\`
860
+
861
+ **When failures or ambiguous rows are non-empty**, follow the header with a small bulleted list (\u2264 5 items): \`<row identifier or domain> \xB7 <reason>\`. Then \`"*+N more \u2014 leadbay_import_status for full detail*"\`.
862
+
863
+ **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
+
865
+ Defer the full list of imported leads to \`leadbay_pull_leads\` or \`leadbay_research_lead\` in NEXT STEPS.
866
+
867
+
868
+ ---
869
+
870
+ ## NEXT STEPS \u2014 after an import
871
+
872
+ | Observation | Suggest | Calls |
873
+ |------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
874
+ | Status: running | "Check progress" | leadbay_import_status(handle_id) |
875
+ | Status: complete, imports succeeded | "Run AI qualification on the imported leads" | leadbay_bulk_qualify_leads([leadIds]) \u2014 or use leadbay_import_and_qualify next time |
876
+ | Ambiguous / unresolved rows present | "Resolve the ambiguous rows" | leadbay_resolve_import_rows(records, identity_mappings)|
877
+ | Failed rows from bad mappings | "Check the org's mappable fields and remap" | leadbay_list_mappable_fields |
878
+ | User wants to see the imported leads | "See the imported leads in your view" | leadbay_pull_leads |
879
+ | User had follow-up intent for the imports | "Prep outreach for [a specific imported lead]" | leadbay_prepare_outreach(leadId) |
777
880
  `;
778
881
  var leadbay_import_status = `Retrieve the current state of an async lead import. Pass \`handle_id\` returned by \`leadbay_import_leads({wait_for_completion:false})\`, or pass legacy \`importIds[]\` to inspect backend wizard rows. This status call performs a single refresh pass and never polls in a loop.
779
882
 
780
883
  WHEN TO USE: after leadbay_import_leads or leadbay_import_and_qualify returns \`{status:'running', handle_id}\` for the import phase, call this tool later to retrieve progress or the final import result without re-running the import.
781
884
 
782
885
  WHEN NOT TO USE: for qualification handles returned as \`qualify_id\` \u2014 use leadbay_qualify_status for those; or when you still want the legacy blocking behavior from leadbay_import_leads with \`wait_for_completion=true\`.
886
+
887
+ ---
888
+
889
+ ## Status / scalar \u2014 single-sentence shape
890
+
891
+ The response is a status confirmation or scalar \u2014 render exactly one sentence inline. Do NOT emit a card or a table. Do NOT enumerate the affected records (that's the next tool's job).
892
+
893
+ Template patterns to follow:
894
+
895
+ - Job kicked off \u2192 \`"\u2713 <Verb> N <noun(s)> \u2014 typically ~M minutes. I'll refresh when it's done."\`
896
+ - No work needed \u2192 \`"All N <noun(s)> already <state> \u2014 no work to do."\`
897
+ - Long-running \u2192 \`"\u23F3 <Verb> still running \u2014 N% complete; check back in ~M minutes."\`
898
+ - Failure \u2192 \`"\u26A0 <Verb> failed: <error>. <recovery hint>"\`
899
+
900
+ After the status line, propose the obvious refresh / progress-check / recovery action in the NEXT STEPS block. Never expand the status into a card.
901
+
902
+
903
+ Specifically for import status:
904
+
905
+ - Running \u2192 \`"\u23F3 Import still running \u2014 N% complete; check back in ~M minutes."\`
906
+ - Complete \u2192 \`"\u2713 Import complete \u2014 N leads imported, M failed."\`
907
+ - Error / failed \u2192 \`"\u26A0 Import failed: <error>. See leadbay_resolve_import_rows for diagnosis."\`
908
+
909
+ ---
910
+
911
+ ## NEXT STEPS
912
+
913
+ | Observation | Suggest | Calls |
914
+ |-------------------------|----------------------------------------|--------------------------------|
915
+ | Status: complete | "See the imported leads" | leadbay_pull_leads |
916
+ | Status: running | "Check again in N minutes" | leadbay_import_status \u2014 re-call|
917
+ | Status: error / failed | "Diagnose the failure" | leadbay_resolve_import_rows |
783
918
  `;
784
919
  var leadbay_launch_bulk_enrichment = `Launch a bulk-enrichment job against the current selection. The backend requires \`email=true\` OR \`phone=true\` (both can be true). Returns 204 with no body \u2014 there is no bulk_id and no per-job status endpoint. Track results by polling individual leads via leadbay_get_contacts after ~60s; \`contact.enrichment.done\` flips to true. \`dry_run:true\` returns the call shape without contacting the backend.
785
920
 
@@ -803,7 +938,35 @@ Optional \`for_records\` param: pass a sample of CSV-shaped rows and the tool al
803
938
 
804
939
  WHEN TO USE: before authoring an import mapping, especially when the CSV has columns that aren't obvious matches for standard fields.
805
940
 
806
- WHEN NOT TO USE: when you already know the mapping \u2014 this call is cheap (~50ms without for_records, ~5\u201310s with) but unnecessary if the agent has already cached the catalog within the same conversation.
941
+ WHEN NOT TO USE: when you already know the mapping \u2014 this call is cheap (~50ms without for_records, ~5\u201310s with) but unnecessary if the agent has already cached the catalog within the same conversation. Cache the result per session; re-fetch only after a \`leadbay_create_custom_field\` call (which can change the catalog).
942
+
943
+ ---
944
+
945
+ ## RENDERING
946
+
947
+ When called mid-import (internal use), do NOT render \u2014 return the data only.
948
+
949
+ When the user asks to inspect the org's fields directly, render two sectioned bulleted lists:
950
+
951
+ ##### Standard fields
952
+
953
+ Bullet each, grouped by domain (lead identity \u2192 location \u2192 sector \u2192 contact \u2192 status). Keep field names verbatim.
954
+
955
+ ##### Custom fields (N defined)
956
+
957
+ Bullet each as \`<name> \xB7 <type> \xB7 \` \`\` \`<mapping_value>\` \`\` (mapping_value in backticks \u2014 agents need it verbatim for import calls).
958
+
959
+ When called with \`for_records\`, append a final section **"Mapping hints for your file"** with the per-column suggestions; flag low-confidence mappings explicitly.
960
+
961
+ ---
962
+
963
+ ## NEXT STEPS
964
+
965
+ | Observation | Suggest | Calls |
966
+ |--------------------------------------------|-------------------------------------------------------------|--------------------------------------------------------|
967
+ | User is preparing an import | "Map a file" | leadbay_resolve_import_rows / leadbay_import_leads |
968
+ | User needs a custom field that doesn't exist | "Create a new custom field" | leadbay_create_custom_field |
969
+ | for_records was passed; hints look good | "Run the import with these mappings" | leadbay_import_leads(records, mappings) |
807
970
  `;
808
971
  var leadbay_list_sectors = `List the sector taxonomy (id + display name in the requested language). Default: \`lang\` follows the caller's language; \`includeInvisible=false\` returns ~1,091 visible sectors.
809
972
 
@@ -827,11 +990,110 @@ WHEN NOT TO USE: from agent flow \u2014 use leadbay_answer_clarification.
827
990
 
828
991
  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\`.
829
992
  `;
830
- var leadbay_prepare_outreach = `Prepare an outreach package for a single lead: recommended contact + enriched contact details + AI summary. Optionally trigger contact enrichment in-flight (\`enrich:true\`); enrichment is async, so poll leadbay_get_contacts after ~60s if you need the result inline.
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.
994
+
995
+ 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\`.
831
996
 
832
- WHEN TO USE: when the agent is about to draft outreach for ONE specific lead and needs the contact's email/phone.
997
+ The first call to this tool on a lead records a \`LEAD_VIEWED\`-style prospecting action server-side (one per lead per session \u2014 deduped) so the lead ages out of the Discover "new" view.
998
+
999
+ IRON LAW \u2014 OUTCOME AFTER OUTREACH. The moment the user reports outreach happened ("I sent it", "she didn't pick up", "left a voicemail", "they replied", a forwarded email thread, a calendar invite), you MUST (1) call leadbay_report_outreach with verification (gmail_message_id, calendar_event_id, or the user's literal one-sentence confirmation as user_confirmed.ref) AND (2) ask the user about the outcome and set epilogue_status to one of the 4 canonical values: EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED ("Meeting booked"), EPILOGUE_COULD_NOT_REACH_STILL_TRYING ("Trying to reach"), EPILOGUE_NOT_INTERESTED_LOST ("Not interested"), EPILOGUE_STILL_CHASING ("In progress"). Use the user-facing labels in dialogue ("What's the outcome \u2014 meeting booked, trying to reach, not interested, or in progress?"); never say "epilogue" out loud. Skipping this step silently de-ranks every future follow-up suggestion because pull_followups depends on honest, current outcomes.
1000
+
1001
+
1002
+ 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.
833
1003
 
834
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).
1005
+
1006
+ ---
1007
+
1008
+ ## RENDERING \u2014 outreach brief (single-record card)
1009
+
1010
+ Present as the richest single-record card the MCP emits. The user is seconds-to-minutes away from contacting someone \u2014 every section earns its place by either (a) telling them HOW to outreach, (b) showing what they've done before, or (c) surfacing what's missing and how to get it.
1011
+
1012
+ **Async enrichment.** When \`enrichment.triggered && !enrichment.complete\`, do NOT block the user. Render the brief with \`\u23F3\` on un-enriched channels and IMMEDIATELY draft a first version of the outreach using whatever data IS available (\`split_ai_summary.approach_angle\`, company-line phone, LinkedIn-search fallback). Tell the user: *"I'll refresh once enriched data lands."* On their next message (or after a clear pause), re-call \`leadbay_prepare_outreach(leadId)\` without \`enrich\`; if \`enrichment.complete: true\`, surface the now-resolved channels and offer to revise the draft.
1013
+
1014
+ ### Structure
1015
+
1016
+ **Header** (H5): \`\u{1F4DE} Outreach prep \u2014 [Contact name](LinkedIn) \xB7 [Company](website)\`
1017
+
1018
+ - Sub-line: job title \xB7 \`+N more contacts\` when \`additional_contacts_count > 0\`.
1019
+ - Prefix \`https://\` to \`website\` if it's a bare hostname.
1020
+
1021
+ **Score line** (when \`lead.score\` is present): the 10-segment bar inline, no \`<br>\`. Same algorithm as \`pull_leads\`.
1022
+
1023
+ **Channel readiness** \u2014 a single line of pill chips, \` \xB7 \`-separated:
1024
+
1025
+ - \`\u{1F517} LinkedIn\` \u2014 \`profile\` (linked to real URL) if \`linkedin_page\` present; \`search\` (linked to people-search fallback) otherwise. \`\u23F3\` during enrichment.
1026
+ - \`\u{1F4E7} Email\` \u2014 show address if present; \`\u23F3 enriching\` when \`enrichment.triggered && !complete\`; \`\u26AA not enriched\` otherwise.
1027
+ - \`\u{1F4DE} Phone\` \u2014 contact-specific number if present; fall back to \`lead.phone_numbers[0]\` with \`(company line)\` annotation; \`\u23F3\` / \`\u26AA\` otherwise.
1028
+
1029
+ **H5: \u{1F3AF} Angles & approach**
1030
+
1031
+ - 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.
1033
+ - 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
+
1035
+ **H5: \u{1F4DC} History with [Contact name]**
1036
+
1037
+ When prior contact-level actions / notes are surfaced (or when \`prospecting_actions_count > 0\`), render a reverse-chronological timeline: \`<date> \xB7 <action_type> \xB7 <one-line summary>\`. Quote-block recent notes below. If empty: \`*No prior touchpoints with this contact.*\`
1038
+
1039
+ **H5: \u{1F3E2} History with [Company name]**
1040
+
1041
+ Same shape as the contact history, but only include items NOT duplicated from the contact section. If both empty: \`*No company-level history recorded.*\`
1042
+
1043
+ **H5: \u{1F465} Other contacts** (only if \`additional_contacts_count > 0\`)
1044
+
1045
+ One line: \`+N more contacts at this company \u2014 [see them all](leadbay_research_company)\`.
1046
+
1047
+ **Closing line** (when enrichment is in progress): \`*Enrichment running \u2014 I'll refresh once email/phone lands.*\`
1048
+
1049
+ **Hide:** \`id\`, \`lead.id\`, raw \`enrichment.hint\` when redundant with channel pills, history items without descriptions, any field whose value is the string \`"null"\`, deprecated \`other_contacts_count\` (use \`additional_contacts_count\`).
1050
+
1051
+ ## Linking a contact's name
1052
+
1053
+ Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
1054
+
1055
+ 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.
1056
+
1057
+ Otherwise fall back to a LinkedIn people-search URL:
1058
+
1059
+ \`\`\`
1060
+ https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1061
+ \`\`\`
1062
+
1063
+ 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.
1064
+
1065
+ 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.
1066
+
1067
+ ## Linking the company
1068
+
1069
+ 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.)
1070
+
1071
+ 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.
1072
+
1073
+ \`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).
1074
+
1075
+
1076
+
1077
+ ---
1078
+
1079
+ ## NEXT STEPS \u2014 after the outreach brief
1080
+
1081
+ 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
+
1083
+ | Observation | Suggest | Calls |
1084
+ |-------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------|
1085
+ | \`enrichment.triggered && !enrichment.complete\` | "Refresh now to check enrichment progress" | leadbay_prepare_outreach(leadId) \u2014 re-call |
1086
+ | Email available | "Draft the outreach email" | (agent self-drafts inline, using split_ai_summary) |
1087
+ | Direct phone available | "Draft the 60-second call opener" | (agent self-drafts inline) |
1088
+ | LinkedIn URL available | "Draft the LinkedIn DM" | (agent self-drafts inline) |
1089
+ | Only company line, no direct phone | "Draft a switchboard script targeting [Contact]" | (agent self-drafts; flag uncertainty) |
1090
+ | \`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) |
1092
+ | User reports they reached out | "Log this outreach \u2014 creates prospecting action + outcome" | leadbay_report_outreach(leadId, contact_id, ...) |
1093
+ | User adds context for next time | "Save a note on the contact or company" | leadbay_add_note |
1094
+ | After a successful exchange | "Update qualification answers based on what you learned" | leadbay_answer_clarification |
1095
+
1096
+ The "log outreach" step is the most-important follow-up \u2014 it closes the loop and populates history for the next \`leadbay_prepare_outreach\` call. Detect intent from natural language: "I sent the email", "she didn't pick up", "left a voicemail", "they responded yes/no", etc.
835
1097
  `;
836
1098
  var leadbay_preview_bulk_enrichment = `Preview a bulk-enrichment cost given a set of job titles applied to the current selection. Returns \`{selected_leads, enriched_contacts, enrichable_contacts, title_suggestions, auto_included_titles, previously_enriched_titles}\`. \`previously_enriched_titles\` is a newer field (in prod soon) \u2014 when present, the agent can recommend repeating those titles for new leads.
837
1099
 
@@ -847,13 +1109,252 @@ WHEN NOT TO USE: as a non-admin (will fail with 403); for personal lens changes
847
1109
 
848
1110
  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\`.
849
1111
  `;
850
- 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 / recommended_contact_title / engagement counters / in-flight flags from the lead summary.
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.
1113
+
1114
+ 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
+
1116
+ **Filter mechanism \u2014 store-then-apply.** Pass \`set_filter: { criteria: FilterCriterion[] }\` to overwrite the server-stored filter, then the composite re-fetches with \`filtered:true\`. \`FilterCriterion\` is the backend's \`anyOf\` over 10 typed criteria: \`size\`, \`keywords\`, \`sector_ids\`, \`location_ids\`, \`custom_field\`, \`custom_field_comparison\`, \`yc\`, \`liked\`, \`last_action\` (filters by MonitorActionType enum), \`last_action_date\` (with \`last_days\` for "last N days").
1117
+
1118
+ Practical mapping from user phrasing to criterion:
1119
+
1120
+ | User phrase | Criterion |
1121
+ |--------------------------------------|---------------------------------------------------------------------------|
1122
+ | "leads in Lyon" | \`{type: "location_ids", locations: [<admin_area_id>]}\` |
1123
+ | "healthcare staffing" | \`{type: "keywords", keywords: ["healthcare", "staffing"]}\` |
1124
+ | "leads I haven't touched in 30 days" | \`{type: "last_action_date", last_days: 30}\` |
1125
+ | "leads I liked" | \`{type: "liked"}\` |
1126
+ | "leads where I purchased contacts" | \`{type: "last_action", types: ["PURCHASE_LEAD_CONTACT"]}\` |
1127
+ | "leads 50\u2013200 employees" | \`{type: "size", sizes: [{min: 50, max: 200}]}\` |
1128
+ | "Y Combinator companies" | \`{type: "yc"}\` |
1129
+
1130
+ 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.
1131
+
1132
+ **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.
1133
+
1134
+ WHEN TO USE: when the user is re-engaging pipeline ("what should I follow up on", "leads in [city]", "stale leads"), or wants to filter their monitored leads by city / sector / recency / action type / liked.
1135
+
1136
+ WHEN NOT TO USE: for NEW leads from the wishlist \u2014 that's \`leadbay_pull_leads\` (discovery). The two surfaces overlap but rank differently; pulling the wrong one wastes the user's time.
1137
+
1138
+ ---
1139
+
1140
+ ## RENDERING \u2014 follow-ups table, status-badge driven
1141
+
1142
+ Markdown table with FOUR columns, sorted by \`last_monitor_action_at\` desc. **NO score bar in this view** \u2014 discovery owns the \`\u25B0\u2756\u25B1\` visual identity; follow-up uses status badges. Active-pushback leads are already excluded server-side.
1143
+
1144
+ **Active-filters line** ABOVE the table, \` \xB7 \`-separated chips from \`active_filters.criteria\`:
1145
+
1146
+ | Criterion type | Chip |
1147
+ |-----------------------|----------------------------|
1148
+ | \`location_ids\` | \u{1F4CD} \\<resolved name\\> |
1149
+ | \`sector_ids\` | \u{1F3F7} \\<sector name\\> |
1150
+ | \`keywords\` | \u{1F50D} \\<keyword\\> |
1151
+ | \`size\` | \u{1F465} \\<min\\>\u2013\\<max\\> |
1152
+ | \`last_action_date\` | \u{1F4C5} \\<window\\> |
1153
+ | \`last_action\` | \u{1F3AF} \\<action types\\> |
1154
+ | \`liked\` / \`yc\` | \u2B50 liked / \u{1F3C5} YC |
1155
+ | \`custom_field*\` | \u2699 \\<field name\\> |
1156
+
1157
+ Render \`*No filters applied.*\` when empty.
1158
+
1159
+ **Column 1 \u2014 Status** (DERIVED from existing fields, priority order):
851
1160
 
852
- 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.
1161
+ 1. \`epilogue_status == "EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED"\` \u2192 \u{1F3AF} Meeting booked
1162
+ 2. \`epilogue_status == "EPILOGUE_COULD_NOT_REACH_STILL_TRYING"\` \u2192 \u26A1 Trying to reach
1163
+ 3. \`epilogue_status == "EPILOGUE_STILL_CHASING"\` + last_prospecting_action_at within 14d \u2192 \u{1F7E2} In progress
1164
+ 4. \`epilogue_status == "EPILOGUE_NOT_INTERESTED_LOST"\` \u2192 \u2744 Not interested (usually filtered out)
1165
+ 5. \`epilogue_status == null\` + \`last_prospecting_action_at == null\` \u2192 \u2728 New
1166
+ 6. \`epilogue_status == null\` + \`last_prospecting_action_at > 60d\` \u2192 \u{1F4A4} Dormant
1167
+ 7. Otherwise \u2192 \u{1F525} Hot
1168
+
1169
+ Append \`<relative time>\` of \`last_monitor_action_at\` (\`today\`/\`Nd\`/\`Nw\`/\`Nmo\`). For \u2728, show \`new\`. Line 2: **[Company](website)** bold. Line 3: short location \xB7 compact size.
1170
+
1171
+ **Column 2 \u2014 AI take** (3 lines from \`split_ai_summary\` verbatim, \`<br>\`-separated):
1172
+
1173
+ - Line 1: emoji + **bold split_ai_summary.worth_pursuing**. Emoji from leading word: \`Yes\u2026\`\u2192\u2705, \`No\u2026\`\u2192\u274C, \`Maybe\u2026\`\u2192\u{1F914}, else\u2192\u{1F4A1}.
1174
+ - Line 2: *italic split_ai_summary.approach_angle* (\u2264 18 words).
1175
+ - Line 3: **Next:** split_ai_summary.next_step.
1176
+ - Fallback when null: render \`ai_summary\` italic, no emoji.
1177
+
1178
+ **Column 3 \u2014 History & notes**
1179
+
1180
+ - Line 1: \`<relative time> ago \xB7 <last_prospecting_action>\` (humanize: snake_case \u2192 Title Case; drop \`LEAD_\` prefix). When null: \`*Never touched.*\`
1181
+ - Line 2 (when \`epilogue_status\` set): \`\u{1F4CC} Outcome: <user-facing label> \xB7 <relative time>\` \u2014 NEVER show the wire-format \`EPILOGUE_*\` value.
1182
+ - Line 3 (when a recent note surfaces): \`\u{1F4DD} *<note clipped \u2264 14 words>* (<rel time>)\`.
1183
+ - If neither: \`*0 prior touches \xB7 0 notes*\`.
1184
+
1185
+ **Column 4 \u2014 Contacts** (max 3 lines, recommended_contact first):
1186
+
1187
+ \`\`\`
1188
+ \u2605 [Name](linkedin_page or people-search) \xB7 \u260E phone \xB7 \u{1F4E7} email
1189
+ \`\`\`
1190
+
1191
+ Markers: \`\u2605\` recommended, \`\u{1F48E}\` hot in web_insights key_people. Channel pills: \`\u260E phone\` (rec.phone_number \u2192 lead.phone_numbers[0] \`(co)\` \u2192 \`\u26AA phone\`); \`\u{1F4E7} email\` (rec.email \u2192 \`\u26AA email\`). When no visible contact has email/direct phone: append \`*enrich first*\` in italic.
1192
+
1193
+ **Hide:** \`id\`, \`location.pos\`, \`web_fetch_in_progress\`, \`enrichment_in_progress\`, \`stale_at\`, \`sector_id\`, zero counters, \`social_presence\` booleans (except \xB0-flag), \`score\`, string \`"null"\`.
1194
+
1195
+ **Legend** (user-facing \u2014 NEVER say "epilogue"):
1196
+
1197
+ \u{1F3AF} Meeting booked \xB7 \u26A1 Trying to reach \xB7 \u{1F7E2} In progress \xB7 \u{1F4A4} Dormant \xB7 \u2728 New \xB7 \u{1F525} Hot \xB7 \u2744 Not interested \xB7 \u2605 recommended \xB7 \u{1F48E} hot in web_insights \xB7 \u260E (co) = company line \xB7 \u26AA not enriched
1198
+
1199
+ ## Linking a contact's name
1200
+
1201
+ Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
1202
+
1203
+ 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.
1204
+
1205
+ Otherwise fall back to a LinkedIn people-search URL:
1206
+
1207
+ \`\`\`
1208
+ https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1209
+ \`\`\`
1210
+
1211
+ 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.
1212
+
1213
+ 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.
1214
+
1215
+ ## Linking the company
1216
+
1217
+ 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.)
1218
+
1219
+ 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.
1220
+
1221
+ \`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).
1222
+
1223
+
1224
+
1225
+ ---
1226
+
1227
+ ## NEXT STEPS \u2014 after the follow-ups table
1228
+
1229
+ 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.
1230
+
1231
+ | Observation | Suggest | Calls |
1232
+ |-----------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------------------------------------------|
1233
+ | Always (top of menu) | "Prep outreach for [top row's contact]" | leadbay_prepare_outreach(leadId) |
1234
+ | User named a city / sector / timeframe | "Refilter by [their phrase]" | leadbay_pull_followups(set_filter: { criteria: [...] }) |
1235
+ | \`pagination.has_more == true\` | "Pull the next page" | leadbay_pull_followups(page = current + 1) |
1236
+ | \u22653 rows \u2728 (never-touched) | "Surface only never-touched leads" | set_filter with \`last_action_date.last_days = 0\` |
1237
+ | \u22653 rows \u26A1 (Trying to reach) | "Focus on overdue commitments" | set_filter with \`last_action.types = ["EPILOGUE_COULD_NOT_REACH_STILL_TRYING"]\` |
1238
+ | User planning a trip / in a city | "Group by city for trip planning" | set_filter with \`location_ids\` (requires admin_area_id) |
1239
+ | All rows last action > 60d | "Re-qualify \u2014 context may have changed" | leadbay_bulk_qualify_leads([leadId, ...]) |
1240
+ | One obvious priority row | "Take me to that lead's full brief" | leadbay_prepare_outreach(leadId) / leadbay_research_lead(leadId) |
1241
+ | User wants to defer a lead | "Snooze [Company] for 3 / 6 / 12 months" | leadbay_set_pushback({ lead_ids:[leadId], status:"3" }) |
1242
+ | User completed outreach mid-flow | "Log the outreach + record the outcome" | leadbay_report_outreach |
1243
+ | Discovery mode might fit better | "Looking for NEW leads instead? Switch to discovery." | leadbay_pull_leads |
1244
+
1245
+ 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.
1246
+ `;
1247
+ 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.
1248
+
1249
+ 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.
1250
+
1251
+ Every lead carries \`recommended_contact\` (with \`linkedin_page\` when the backend has it), \`phone_numbers\` (when available), \`split_ai_summary.{worth_pursuing, approach_angle, next_step}\` (when AI-qualified), and \`social_urls\` per-platform (linkedin, instagram, tiktok, facebook, twitter, crunchbase). Use them \u2014 they're already in the response, you don't need a second call.
853
1252
 
854
1253
  WHEN TO USE: as the agent's default opening move when the user wants to see leads, or as a daily check-in for what's new today.
855
1254
 
856
1255
  WHEN NOT TO USE: when the user has named a specific lens \u2014 pass \`lensId\` to override the auto-resolution. Replaces the older leadbay_find_prospects (removed in v0.2.0).
1256
+
1257
+ The active lens can change between calls (5-min cache + backend \`last_requested_lens\`). If a multi-step workflow depends on staying on one lens, **capture \`response.lens.id\` from the first response and pass it as the \`lensId\` argument on every subsequent Leadbay call** \u2014 including re-pulls, bulk qualifies, and research. (Field-name caveat: response nests it as \`lens.id\`; the parameter is \`lensId\`.) Re-pulling without \`lensId\` after a long-running tool may silently switch to a different lens and discard prior work.
1258
+
1259
+ ---
1260
+
1261
+ ## RENDERING \u2014 markdown table, three columns, score-bar driven
1262
+
1263
+ Present the response as a markdown table sorted by \`score\` descending, with exactly three columns. Do not summarize in prose. Do not show the numeric score anywhere.
1264
+
1265
+ ## Score-bar (10-segment, inline-code wrapped)
1266
+
1267
+ Wrap a 10-glyph bar in a SINGLE inline-code span (backticks). The inline-code styling is what gives the bar contrast in most chat renderers \u2014 HTML \`<span>\` is stripped inside table cells.
1268
+
1269
+ Glyphs (use these exact characters; do not substitute):
1270
+
1271
+ - \`\u25B0\` \u2014 firmographic-only fill
1272
+ - \`\u2756\` \u2014 AI-booster cap (placed at the RIGHT END of the filled run, never the front)
1273
+ - \`\u25B1\` \u2014 empty
1274
+
1275
+ Computation:
1276
+
1277
+ \`\`\`
1278
+ total_filled = round(score / 10), clamped to 0..10
1279
+ ai_segments = round(qualification_summary.avg_qualification_boost / 3.3),
1280
+ clamped to [0, total_filled]
1281
+ normal_filled = total_filled \u2212 ai_segments
1282
+ bar = "\u25B0" \xD7 normal_filled
1283
+ + "\u2756" \xD7 ai_segments
1284
+ + "\u25B1" \xD7 (10 \u2212 total_filled)
1285
+ \`\`\`
1286
+
1287
+ If \`qualification_summary.answered == 0\` or \`avg_qualification_boost\` is null, set \`ai_segments = 0\` (no \u2756). Always wrap the bar in backticks. Print the legend \`\` \`\u25B0\` firmographic \xB7 \`\u2756\` AI booster cap \xB7 \`\u25B1\` unfilled \`\` once below the table.
1288
+
1289
+
1290
+ **Column 1 \u2014 Company**
1291
+
1292
+ - Line 1: the 10-segment score bar in inline-code backticks (see the score-bar snippet above for the algorithm).
1293
+ - Insert \`<br>\` between lines.
1294
+ - Line 2: linked company name + \` \xB7 \` + short location + \` \xB7 \` + compact size.
1295
+ - Link target: \`website\` (prefix \`https://\` if it's a bare hostname). Don't synthesize an app deep-link.
1296
+ - Location: shorten "City of New York" \u2192 "NYC"; otherwise "City ST"; state alone only when city missing.
1297
+ - Size: \`"Xk+"\` when \`size.min >= 1000\`, \`"min\u2013max"\` otherwise.
1298
+
1299
+ **Column 2 \u2014 Why it fits**
1300
+
1301
+ - One sentence, \u2264 20 words.
1302
+ - 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\`.
1303
+ - Do NOT append \`(boost N)\` \u2014 the \u2756 cap in column 1 already carries that signal.
1304
+ - No bullet lists, no line breaks inside the cell.
1305
+
1306
+ **Column 3 \u2014 Contact**
1307
+
1308
+ \`[Contact name](LINK) \xB7 short job title\`. See linking/contact-linkedin for LINK priority and the \xB0-flag fallback.
1309
+
1310
+ **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"\`.
1311
+
1312
+ ## Linking a contact's name
1313
+
1314
+ Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
1315
+
1316
+ 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.
1317
+
1318
+ Otherwise fall back to a LinkedIn people-search URL:
1319
+
1320
+ \`\`\`
1321
+ https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1322
+ \`\`\`
1323
+
1324
+ 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.
1325
+
1326
+ 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.
1327
+
1328
+ ## Linking the company
1329
+
1330
+ 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.)
1331
+
1332
+ 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.
1333
+
1334
+ \`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).
1335
+
1336
+
1337
+
1338
+ ---
1339
+
1340
+ ## NEXT STEPS \u2014 after rendering the pull_leads table
1341
+
1342
+ 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.
1343
+
1344
+ | Observation | Suggest | Calls |
1345
+ |------------------------------------------------------------|--------------------------------------------------------------|--------------------------------------------------------|
1346
+ | \`has_more == true\` | "Pull the next page (page N+1 of M)" | leadbay_pull_leads(page = current + 1, lensId = pinned)|
1347
+ | \u2265 3 rows have \`qualification_summary.answered == 0\` | "Deepen AI qualification on the rows without \u2756 caps" | leadbay_bulk_qualify_leads(leadIds=[\u2026]) |
1348
+ | User points at a single row | "Research [Company] in depth" | leadbay_research_lead(leadId) |
1349
+ | User wants the company-level web view | "Pull the company-level research for [Company]" | leadbay_research_company({leadId} or {companyName}) |
1350
+ | Top row has phone AND email | "Prepare an outreach for [Contact] \u2014 call + email" | leadbay_prepare_outreach(leadId) |
1351
+ | Top row has email but no phone | "Draft an outreach email for [Contact]" | leadbay_prepare_outreach(leadId) |
1352
+ | Top row has phone but no email | "Show [Contact]'s call details + a 60-second opener" | leadbay_prepare_outreach(leadId) |
1353
+ | Top row has contacts but no phone/email | "Order contact enrichment to surface email/phone first" | leadbay_enrich_titles(...) or leadbay_prepare_outreach(leadId, enrich:true) |
1354
+ | \`computing_scores == true\` or \`computing_wishlist == true\` | "Scores are still being computed \u2014 re-pull in ~30s" | leadbay_pull_leads (retry with same lensId) |
1355
+ | User wants a narrower / wider audience | "Adjust the lens filters (sector / size)" | leadbay_adjust_audience(...) |
1356
+
1357
+ If nothing in the menu applies cleanly, suggest only "pull next page" and "research a specific lead in depth" \u2014 never invent a tool that doesn't exist.
857
1358
  `;
858
1359
  var leadbay_qualify_lead = `Trigger AI qualification for a single lead (web fetch + AI rescore). The operation is asynchronous \u2014 results take ~60s. \`forceFetch:true\` re-runs even if recent data exists.
859
1360
 
@@ -889,6 +1390,16 @@ WHEN TO USE: when an outreach action was logged in error and needs to be undone.
889
1390
 
890
1391
  WHEN NOT TO USE: to change status \u2014 call leadbay_set_epilogue_status with the new status (it overwrites).
891
1392
 
1393
+ 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\`.
1394
+ `;
1395
+ var leadbay_remove_pushback = `Clear an active pushback on one or more leads. They re-enter \`leadbay_pull_followups\` immediately. Use when the user changes their mind ("actually let's reach out now"), or when external context shifts (the company announces hiring / funding / a new product that makes them ripe sooner than the deferral window expected).
1396
+
1397
+ Bulk-native: pass up to 1000 lead UUIDs per call. No status enum \u2014 pushback is binary (either an active window exists, or it doesn't).
1398
+
1399
+ WHEN TO USE: the user wants to revive a snoozed lead now, ahead of the pushback window expiry.
1400
+
1401
+ WHEN NOT TO USE: the pushback expired naturally (no API call needed; the lead reappears in \`pull_followups\` automatically).
1402
+
892
1403
  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\`.
893
1404
  `;
894
1405
  var leadbay_report_outreach = `Log an outreach action (email, call, message, meeting) on a lead so the human team using Leadbay sees the progress in their UI. Writes a NOTE on the lead and (optionally) sets an EPILOGUE status (still chasing, meeting booked, etc.). Bulk variant: pass \`lead_ids=[uuid,...]\` instead of \`lead_id\` (epilogue is bulk-native; notes fan out per-lead).
@@ -903,9 +1414,120 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
903
1414
  `;
904
1415
  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).
905
1416
 
906
- WHEN TO USE: when the user references a company by name and you don't yet have its \`lead_id\`.
1417
+ 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).
1418
+
1419
+ 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.
1420
+
1421
+ WHEN NOT TO USE: when you already have the lead_id and need the bundled deeper lens-scoped data \u2014 use leadbay_research_lead.
1422
+
1423
+ ---
1424
+
1425
+ ## RENDERING \u2014 single-record research card, mode-adaptive
1426
+
1427
+ 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.
1428
+
1429
+ **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.
1430
+
1431
+ **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\`.
1432
+
1433
+ Default to MODE A when uncertain. Always offer the cross-mode pivot at the end so the user can redirect if you guessed wrong.
1434
+
1435
+ ### Common structure (both modes)
1436
+
1437
+ - **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.
1438
+ - **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.
1439
+ - **Blurb**: render \`description\` (preferred) or \`short_description\` as a single blockquoted paragraph.
1440
+ - **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.
1441
+ - **Contacts table** (always at the bottom):
1442
+ \`\`\`
1443
+ | | Name | Title | LinkedIn |
1444
+ \`\`\`
1445
+ Markers in column 1:
1446
+ - \`\u2605\` \u2014 \`recommended_contact\` match.
1447
+ - \`\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.)
1448
+ 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"\`.
1449
+
1450
+ ### MODE A body (Discovery, fuller, scannable)
1451
+
1452
+ 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).
1453
+
1454
+ ### MODE B body (Contact preparation, tighter)
1455
+
1456
+ Render exactly two H5 sections:
1457
+
1458
+ ##### \u{1F3AF} Conversation hooks
907
1459
 
908
- WHEN NOT TO USE: when you already have the lead_id \u2014 use leadbay_research_lead directly (it bundles richer signals + better top-down ordering for the agent).
1460
+ 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.
1461
+
1462
+ ##### \u{1F464} About the person *(only when recommended_contact is non-empty)*
1463
+
1464
+ 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.
1465
+
1466
+ 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.
1467
+
1468
+ If \`qualification[]\` is non-empty, append one collapsed line: \`"Qualification: N questions answered, avg boost X"\` and offer to expand in NEXT STEPS.
1469
+
1470
+ **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.
1471
+
1472
+ **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) \`\`
1473
+
1474
+ ## Linking a contact's name
1475
+
1476
+ Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
1477
+
1478
+ 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.
1479
+
1480
+ Otherwise fall back to a LinkedIn people-search URL:
1481
+
1482
+ \`\`\`
1483
+ https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1484
+ \`\`\`
1485
+
1486
+ 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.
1487
+
1488
+ 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.
1489
+
1490
+ ## Linking the company
1491
+
1492
+ 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.)
1493
+
1494
+ 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.
1495
+
1496
+ \`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).
1497
+
1498
+
1499
+
1500
+ ---
1501
+
1502
+ ## NEXT STEPS \u2014 after the research card
1503
+
1504
+ 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.
1505
+
1506
+ ### MODE A (Discovery)
1507
+
1508
+ | Observation | Suggest | Calls |
1509
+ |--------------------------------------------------------|----------------------------------------------------|----------------------------------------------------|
1510
+ | \`qualification[]\` is empty | "Run AI qualification on this lead" | leadbay_bulk_qualify_leads([leadId]) |
1511
+ | \u22651 hot recent item in \u{1F4C8} business signals | "Prepare outreach referencing [signal headline]" | leadbay_prepare_outreach(leadId) |
1512
+ | \`contacts_count > len(contacts)\` shown | "Pull the full contact list (N more)" | leadbay_get_contacts(leadId) |
1513
+ | \`web_insights_fetched_at\` > 30 days | "Re-run the web research \u2014 this is stale" | leadbay_research_company(leadId) \u2014 refresh |
1514
+ | User wants the deeper lens-scoped bundle | "Pull the full lead profile (research_lead)" | leadbay_research_lead(leadId) |
1515
+ | User is exploring multiple companies | "Back to the lead list" | leadbay_pull_leads |
1516
+ | \`qualification[]\` non-empty | "Expand the AI qualification answers" | (render qualification[] as a sub-card) |
1517
+
1518
+ End MODE A with the pivot offer: \`"Want the contact-prep view for [recommended contact name]?"\`
1519
+
1520
+ ### MODE B (Contact preparation)
1521
+
1522
+ | Observation | Suggest | Calls |
1523
+ |--------------------------------------------------------|----------------------------------------------------|----------------------------------------------------|
1524
+ | \`phone_numbers[]\` non-empty | "Show full call notes + a 60-second opener" | leadbay_prepare_outreach(leadId) |
1525
+ | Recommended contact has an email | "Draft the outreach email" | leadbay_prepare_outreach(leadId) |
1526
+ | Neither phone nor email for recommended contact | "Order contact enrichment first" | leadbay_prepare_outreach(leadId, enrich:true) or leadbay_enrich_titles |
1527
+ | After the user reports a touchpoint | "Log the call/email outcome" | leadbay_report_outreach |
1528
+ | Adding pre-call context | "Add a note to this lead" | leadbay_add_note |
1529
+
1530
+ End MODE B with the pivot offer: \`"Want the full strategic overview instead?"\`
909
1531
  `;
910
1532
  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.
911
1533
 
@@ -914,12 +1536,46 @@ Scoring has two layers: the basic \`score\` (firmographic, always present, alrea
914
1536
  WHEN TO USE: when picking up a single lead from leadbay_pull_leads to decide whether to act on it.
915
1537
 
916
1538
  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.)
1539
+
1540
+ **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.
917
1541
  `;
918
1542
  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\`.
919
1543
 
920
1544
  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.
921
1545
 
922
1546
  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.
1547
+
1548
+ ---
1549
+
1550
+ ## RENDERING
1551
+
1552
+ Present as a markdown table of resolution outcomes per row.
1553
+
1554
+ \`\`\`
1555
+ | Source row | Match status | Resolved leadId / candidates |
1556
+ \`\`\`
1557
+
1558
+ Status emoji map:
1559
+
1560
+ - \`\u2713\` unambiguous match \u2014 one Leadbay lead, high confidence
1561
+ - \`\u26A0\` ambiguous \u2014 multiple candidates returned
1562
+ - \`\u2717\` no match \u2014 backend has no candidates; can still import if \`website\` is present
1563
+ - \`\u23F3\` resolving \u2014 async resolve still running
1564
+
1565
+ For ambiguous rows, list up to 3 candidate names + leadIds inline. Truncate the source row to a short identifier (first non-empty column or domain).
1566
+
1567
+ Below the table, a one-liner: \`"Ready: K rows \xB7 Ambiguous: A rows \xB7 Unmatched: U rows"\`
1568
+
1569
+ ---
1570
+
1571
+ ## NEXT STEPS
1572
+
1573
+ | Observation | Suggest | Calls |
1574
+ |----------------------------------------|-------------------------------------------------------------|--------------------------------------------------------|
1575
+ | All rows resolved cleanly | "Import these rows now" | leadbay_import_leads(records_for_import, mappings_for_import) |
1576
+ | Ambiguous rows present | "Inspect candidates for each ambiguous row" | (re-call with include_candidate_profiles=true) |
1577
+ | Unmatched rows but websites present | "Import anyway \u2014 Leadbay will crawl and match later" | leadbay_import_leads (status check after) |
1578
+ | User wants to skip rows they can't ID | "Drop unmatched rows and import the rest" | leadbay_import_leads (with filtered records) |
923
1579
  `;
924
1580
  var leadbay_select_leads = `Add leads to the user's transient selection (used by selection-scoped bulk operations). Accepts 1-1000 \`leadIds\` per call.
925
1581
 
@@ -943,6 +1599,18 @@ WHEN TO USE: low-level.
943
1599
 
944
1600
  WHEN NOT TO USE: from agent flow \u2014 leadbay_report_outreach pairs this with a note + verification, which is what humans actually need to see in Leadbay.
945
1601
 
1602
+ 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\`.
1603
+ `;
1604
+ var leadbay_set_pushback = `Snooze (pushback) one or more leads for 3, 6, or 12 months. The leads remain in the user's pipeline but are excluded from \`leadbay_pull_followups\` until the pushback window expires. Use this when the user says "not now", "next quarter", "follow up in 3 months", "6 months out", "next year", or any equivalent deferral.
1605
+
1606
+ Accepts short labels (\`3\`, \`6\`, \`12\` \u2014 months) or the wire-format enum (\`PUSHBACK_3\`, \`PUSHBACK_6\`, \`PUSHBACK_12\`). Bulk-native: pass up to 1000 lead UUIDs per call.
1607
+
1608
+ User-facing language: "snooze for 3/6/12 months" (or "pushback" if the user uses the term themselves). Never say "pushback status" in dialogue \u2014 say "snooze" or "follow up in N months".
1609
+
1610
+ WHEN TO USE: the user explicitly says they want to defer a lead (or a small set of leads) for a known window. Pair with \`leadbay_add_note\` if the user's reason for deferring is worth preserving.
1611
+
1612
+ WHEN NOT TO USE: the user is just skipping ONE outreach attempt \u2014 that's a normal \`EPILOGUE_COULD_NOT_REACH_STILL_TRYING\` outcome via \`leadbay_report_outreach\`, not a snooze.
1613
+
946
1614
  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\`.
947
1615
  `;
948
1616
  var leadbay_set_user_prompt = `Set the org's intelligence-refinement prompt \u2014 free-text instruction that steers Leadbay's lead recommendations beyond firmographics. Admin-only. Setting this clears any pending clarification and triggers a full intelligence regeneration (web search + high-reasoning). \`dry_run:true\` returns the call shape without contacting the backend.
@@ -1134,6 +1802,16 @@ var discoverLeads = {
1134
1802
  };
1135
1803
 
1136
1804
  // ../core/dist/tools/get-lead-profile.js
1805
+ function normalizeLinkedinPage(v) {
1806
+ if (v == null)
1807
+ return null;
1808
+ if (typeof v !== "string")
1809
+ return null;
1810
+ const trimmed = v.trim();
1811
+ if (!trimmed || trimmed.toLowerCase() === "null")
1812
+ return null;
1813
+ return trimmed;
1814
+ }
1137
1815
  var getLeadProfile = {
1138
1816
  name: "leadbay_get_lead_profile",
1139
1817
  annotations: {
@@ -1210,7 +1888,7 @@ var getLeadProfile = {
1210
1888
  last_name: c.last_name,
1211
1889
  email: c.email,
1212
1890
  phone_number: c.phone_number,
1213
- linkedin_page: c.linkedin_page,
1891
+ linkedin_page: normalizeLinkedinPage(c.linkedin_page),
1214
1892
  job_title: c.job_title,
1215
1893
  recommended: c.recommended,
1216
1894
  enrichment: c.enrichment,
@@ -1222,7 +1900,7 @@ var getLeadProfile = {
1222
1900
  last_name: c.last_name,
1223
1901
  email: c.email,
1224
1902
  phone_number: c.phone_number,
1225
- linkedin_page: c.linkedin_page,
1903
+ linkedin_page: normalizeLinkedinPage(c.linkedin_page),
1226
1904
  job_title: c.job_title,
1227
1905
  recommended: c.recommended,
1228
1906
  enrichment: c.enrichment,
@@ -1247,8 +1925,14 @@ var getLeadProfile = {
1247
1925
  phone_numbers: lead.phone_numbers,
1248
1926
  keywords: lead.keywords,
1249
1927
  contacts_count: lead.contacts_count,
1250
- recommended_contact_title: lead.recommended_contact_title ?? null,
1251
- recommended_contact: lead.recommended_contact ?? null,
1928
+ // B8: recommended_contact_title dropped — duplicates
1929
+ // recommended_contact.job_title. B1+B7: propagate linkedin_page.
1930
+ recommended_contact: lead.recommended_contact ? {
1931
+ ...lead.recommended_contact,
1932
+ linkedin_page: normalizeLinkedinPage(lead.recommended_contact.linkedin_page ?? null)
1933
+ } : null,
1934
+ social_presence: lead.social_presence ?? null,
1935
+ social_urls: lead.social_urls ?? null,
1252
1936
  web_fetch_in_progress: lead.web_fetch_in_progress ?? false
1253
1937
  },
1254
1938
  qualification: qualification?.map((q) => ({
@@ -4030,6 +4714,100 @@ var removeEpilogue = {
4030
4714
  }
4031
4715
  };
4032
4716
 
4717
+ // ../core/dist/tools/set-pushback.js
4718
+ var PUSHBACK_LABEL_MAP = {
4719
+ "3": "PUSHBACK_3",
4720
+ "6": "PUSHBACK_6",
4721
+ "12": "PUSHBACK_12",
4722
+ "3m": "PUSHBACK_3",
4723
+ "6m": "PUSHBACK_6",
4724
+ "12m": "PUSHBACK_12",
4725
+ "3_months": "PUSHBACK_3",
4726
+ "6_months": "PUSHBACK_6",
4727
+ "12_months": "PUSHBACK_12",
4728
+ PUSHBACK_3: "PUSHBACK_3",
4729
+ PUSHBACK_6: "PUSHBACK_6",
4730
+ PUSHBACK_12: "PUSHBACK_12"
4731
+ };
4732
+ var setPushback = {
4733
+ name: "leadbay_set_pushback",
4734
+ annotations: {
4735
+ title: "Pushback (snooze) leads for 3 / 6 / 12 months",
4736
+ readOnlyHint: false,
4737
+ destructiveHint: true,
4738
+ idempotentHint: true,
4739
+ openWorldHint: true
4740
+ },
4741
+ description: leadbay_set_pushback,
4742
+ optional: true,
4743
+ write: true,
4744
+ inputSchema: {
4745
+ type: "object",
4746
+ properties: {
4747
+ lead_ids: {
4748
+ type: "array",
4749
+ items: { type: "string" },
4750
+ description: "Lead UUIDs (1-1000)"
4751
+ },
4752
+ status: {
4753
+ type: "string",
4754
+ description: "One of: 3, 6, 12 (months) \u2014 or the long form PUSHBACK_3 / PUSHBACK_6 / PUSHBACK_12."
4755
+ }
4756
+ },
4757
+ required: ["lead_ids", "status"],
4758
+ additionalProperties: false
4759
+ },
4760
+ execute: async (client, params) => {
4761
+ const wire = PUSHBACK_LABEL_MAP[String(params.status)];
4762
+ if (!wire) {
4763
+ return {
4764
+ error: true,
4765
+ code: "BAD_INPUT",
4766
+ message: `Unknown pushback status: ${params.status}`,
4767
+ hint: "Use one of: 3, 6, 12 (months) \u2014 or PUSHBACK_3 / PUSHBACK_6 / PUSHBACK_12."
4768
+ };
4769
+ }
4770
+ await client.requestVoid("POST", "/leads/pushback", {
4771
+ lead_ids: params.lead_ids,
4772
+ status: wire
4773
+ });
4774
+ return { applied: true, count: params.lead_ids.length, status: wire };
4775
+ }
4776
+ };
4777
+
4778
+ // ../core/dist/tools/remove-pushback.js
4779
+ var removePushback = {
4780
+ name: "leadbay_remove_pushback",
4781
+ annotations: {
4782
+ title: "Remove pushback (un-snooze) leads",
4783
+ readOnlyHint: false,
4784
+ destructiveHint: true,
4785
+ idempotentHint: true,
4786
+ openWorldHint: true
4787
+ },
4788
+ description: leadbay_remove_pushback,
4789
+ optional: true,
4790
+ write: true,
4791
+ inputSchema: {
4792
+ type: "object",
4793
+ properties: {
4794
+ lead_ids: {
4795
+ type: "array",
4796
+ items: { type: "string" },
4797
+ description: "Lead UUIDs"
4798
+ }
4799
+ },
4800
+ required: ["lead_ids"],
4801
+ additionalProperties: false
4802
+ },
4803
+ execute: async (client, params) => {
4804
+ await client.requestVoid("POST", "/leads/remove_pushback", {
4805
+ lead_ids: params.lead_ids
4806
+ });
4807
+ return { cleared: true, count: params.lead_ids.length };
4808
+ }
4809
+ };
4810
+
4033
4811
  // ../core/dist/tools/preview-bulk-enrichment.js
4034
4812
  var previewBulkEnrichment = {
4035
4813
  name: "leadbay_preview_bulk_enrichment",
@@ -4300,6 +5078,16 @@ var researchCompany = {
4300
5078
  };
4301
5079
 
4302
5080
  // ../core/dist/composite/prepare-outreach.js
5081
+ function normalizeLinkedinPage2(v) {
5082
+ if (v == null)
5083
+ return null;
5084
+ if (typeof v !== "string")
5085
+ return null;
5086
+ const trimmed = v.trim();
5087
+ if (!trimmed || trimmed.toLowerCase() === "null")
5088
+ return null;
5089
+ return trimmed;
5090
+ }
4303
5091
  var prepareOutreach = {
4304
5092
  name: "leadbay_prepare_outreach",
4305
5093
  annotations: {
@@ -4320,7 +5108,7 @@ var prepareOutreach = {
4320
5108
  },
4321
5109
  enrich: {
4322
5110
  type: "boolean",
4323
- description: "If true and credits available, trigger enrichment on the recommended contact (default: false). Enrichment is async \u2014 poll leadbay_get_contacts after ~60s."
5111
+ description: "If true and credits available, trigger enrichment on the recommended contact (default: false). Enrichment is async; re-call this tool (no enrich) after ~60s and check enrichment.complete to see if email/phone landed (B13: self-polling)."
4324
5112
  }
4325
5113
  },
4326
5114
  required: ["leadId"],
@@ -4331,32 +5119,67 @@ var prepareOutreach = {
4331
5119
  properties: {
4332
5120
  lead: {
4333
5121
  type: ["object", "null"],
4334
- description: "Short lead summary for outreach context: {name, ai_summary, website}. Null if /lead profile fetch failed.",
5122
+ description: "Lead context for the brief. Expanded per B15: score, ai_summary, split_ai_summary, location, size, phone_numbers, website, description.",
4335
5123
  properties: {
5124
+ id: { type: "string" },
4336
5125
  name: { type: "string" },
5126
+ score: { type: ["number", "null"] },
5127
+ ai_agent_lead_score: { type: ["number", "null"] },
4337
5128
  ai_summary: { type: ["string", "null"] },
4338
- website: { type: ["string", "null"] }
5129
+ split_ai_summary: { type: ["object", "null"] },
5130
+ location: { type: ["object", "null"] },
5131
+ size: { type: ["object", "null"] },
5132
+ phone_numbers: { type: ["array", "null"], items: { type: "string" } },
5133
+ website: { type: ["string", "null"] },
5134
+ description: { type: ["string", "null"] },
5135
+ short_description: { type: ["string", "null"] },
5136
+ social_presence: { type: ["object", "null"] },
5137
+ social_urls: { type: ["object", "null"] }
4339
5138
  }
4340
5139
  },
4341
5140
  recommended_contact: {
4342
5141
  type: ["object", "null"],
4343
- description: "Best contact to outreach to ({id, name, job_title, email, phone_number, linkedin_page}). Null when no contacts known."
5142
+ description: "Best contact to outreach to. Always returned in the post-enrichment shape (B21) \u2014 first_name/last_name/contact_id/email/phone_number/linkedin_page/job_title \u2014 with nulls in fields that aren't yet enriched.",
5143
+ properties: {
5144
+ contact_id: { type: ["string", "null"] },
5145
+ first_name: { type: ["string", "null"] },
5146
+ last_name: { type: ["string", "null"] },
5147
+ job_title: { type: ["string", "null"] },
5148
+ email: { type: ["string", "null"] },
5149
+ phone_number: { type: ["string", "null"] },
5150
+ linkedin_page: { type: ["string", "null"] },
5151
+ is_org_contact: { type: ["boolean", "null"] }
5152
+ }
5153
+ },
5154
+ additional_contacts_count: {
5155
+ type: "number",
5156
+ description: "How many other contacts exist beyond the recommended one (renamed from other_contacts_count per B16; both shipped for one release)."
5157
+ },
5158
+ total_contacts_count: {
5159
+ type: "number",
5160
+ description: "Total contacts on this lead (recommended + others)."
4344
5161
  },
4345
5162
  other_contacts_count: {
4346
5163
  type: "number",
4347
- description: "How many other contacts exist beyond the recommended one (so the agent knows there's more to discover via leadbay_get_contacts)."
5164
+ description: "DEPRECATED: alias for additional_contacts_count. Will be removed in 0.10.0."
4348
5165
  },
4349
5166
  enrichment: {
4350
5167
  type: "object",
4351
- description: "Status of opt-in enrichment (only set when enrich:true was passed): {triggered, error, hint}.",
5168
+ description: "Self-polling status (B13): triggered = whether this call kicked off enrichment; complete = whether the recommended contact now has email OR phone.",
4352
5169
  properties: {
4353
5170
  triggered: { type: "boolean" },
5171
+ complete: { type: "boolean" },
4354
5172
  error: { type: ["string", "null"] },
4355
5173
  hint: { type: ["string", "null"] }
4356
5174
  }
4357
5175
  }
4358
5176
  },
4359
- required: ["recommended_contact", "other_contacts_count", "enrichment"]
5177
+ required: [
5178
+ "recommended_contact",
5179
+ "additional_contacts_count",
5180
+ "total_contacts_count",
5181
+ "enrichment"
5182
+ ]
4360
5183
  },
4361
5184
  execute: async (client, params, ctx) => {
4362
5185
  const contactsResult = await getContacts.execute(client, { leadId: params.leadId }, ctx);
@@ -4372,37 +5195,83 @@ var prepareOutreach = {
4372
5195
  enrichmentError = e?.message ?? String(e);
4373
5196
  }
4374
5197
  }
4375
- let leadSummary = null;
5198
+ let refreshed = contacts;
5199
+ if (enrichmentTriggered) {
5200
+ try {
5201
+ const again = await getContacts.execute(client, { leadId: params.leadId }, ctx);
5202
+ refreshed = again.contacts ?? contacts;
5203
+ } catch {
5204
+ }
5205
+ }
5206
+ const recommendedFresh = refreshed.find((c) => c.recommended) ?? recommended;
5207
+ let leadBlock = null;
4376
5208
  try {
4377
5209
  const profile = await getLeadProfile.execute(client, { leadId: params.leadId }, ctx);
4378
- leadSummary = {
4379
- name: profile.lead.name,
4380
- ai_summary: profile.lead.ai_summary,
4381
- website: profile.lead.website
5210
+ const p = profile.lead;
5211
+ leadBlock = {
5212
+ id: p.id ?? params.leadId,
5213
+ name: p.name ?? null,
5214
+ score: p.score ?? null,
5215
+ ai_agent_lead_score: p.ai_agent_lead_score ?? null,
5216
+ ai_summary: p.ai_summary ?? null,
5217
+ split_ai_summary: p.split_ai_summary ?? null,
5218
+ location: p.location ?? null,
5219
+ size: p.size ?? null,
5220
+ phone_numbers: p.phone_numbers ?? null,
5221
+ website: p.website ?? null,
5222
+ description: p.description ?? null,
5223
+ short_description: p.short_description ?? null,
5224
+ social_presence: p.social_presence ?? null,
5225
+ social_urls: p.social_urls ?? null
4382
5226
  };
4383
5227
  } catch {
5228
+ leadBlock = {
5229
+ id: params.leadId,
5230
+ name: null,
5231
+ ai_summary: null
5232
+ };
4384
5233
  }
5234
+ const recommendedContact = recommendedFresh ? {
5235
+ contact_id: recommendedFresh.id ?? null,
5236
+ first_name: recommendedFresh.first_name,
5237
+ last_name: recommendedFresh.last_name,
5238
+ job_title: recommendedFresh.job_title,
5239
+ email: recommendedFresh.email,
5240
+ phone_number: recommendedFresh.phone_number,
5241
+ linkedin_page: normalizeLinkedinPage2(recommendedFresh.linkedin_page),
5242
+ is_org_contact: recommendedFresh.source === "org"
5243
+ } : null;
5244
+ const total = refreshed.length;
5245
+ const additional = recommendedFresh ? Math.max(0, total - 1) : total;
5246
+ const enrichmentComplete = Boolean(recommendedContact && (recommendedContact.email || recommendedContact.phone_number));
4385
5247
  return {
4386
- lead: leadSummary,
4387
- recommended_contact: recommended ? {
4388
- id: recommended.id,
4389
- name: [recommended.first_name, recommended.last_name].filter(Boolean).join(" "),
4390
- job_title: recommended.job_title,
4391
- email: recommended.email,
4392
- phone_number: recommended.phone_number,
4393
- linkedin_page: recommended.linkedin_page
4394
- } : null,
4395
- other_contacts_count: Math.max(0, contacts.length - 1),
5248
+ lead: leadBlock,
5249
+ recommended_contact: recommendedContact,
5250
+ additional_contacts_count: additional,
5251
+ total_contacts_count: total,
5252
+ // Deprecated alias kept for one release.
5253
+ other_contacts_count: additional,
4396
5254
  enrichment: {
4397
5255
  triggered: enrichmentTriggered,
5256
+ complete: enrichmentComplete,
4398
5257
  error: enrichmentError,
4399
- hint: enrichmentTriggered ? "Enrichment started. Poll leadbay_get_contacts with the same leadId in ~60 seconds." : null
5258
+ hint: enrichmentTriggered && !enrichmentComplete ? "Enrichment running (~60s). Re-call leadbay_prepare_outreach with the same leadId (no enrich) and check enrichment.complete." : null
4400
5259
  }
4401
5260
  };
4402
5261
  }
4403
5262
  };
4404
5263
 
4405
5264
  // ../core/dist/composite/pull-leads.js
5265
+ function normalizeLinkedinPage3(v) {
5266
+ if (v == null)
5267
+ return null;
5268
+ if (typeof v !== "string")
5269
+ return null;
5270
+ const trimmed = v.trim();
5271
+ if (!trimmed || trimmed.toLowerCase() === "null")
5272
+ return null;
5273
+ return trimmed;
5274
+ }
4406
5275
  function summarise(responses) {
4407
5276
  const answered = responses.filter((r) => r.score != null).length;
4408
5277
  const total = responses.length;
@@ -4514,18 +5383,29 @@ var pullLeads = {
4514
5383
  }
4515
5384
  }));
4516
5385
  const summaryMap = new Map(summaries.map((s) => [s.leadId, s.summary]));
4517
- const trimmed = (lead) => verbose ? lead : {
5386
+ const augmentContact2 = (c) => c ? {
5387
+ ...c,
5388
+ linkedin_page: normalizeLinkedinPage3(c.linkedin_page ?? null)
5389
+ } : null;
5390
+ const trimmed = (lead) => verbose ? {
5391
+ ...lead,
5392
+ recommended_contact: augmentContact2(lead.recommended_contact)
5393
+ } : {
4518
5394
  id: lead.id,
4519
5395
  name: lead.name,
4520
5396
  score: lead.score,
4521
5397
  ai_agent_lead_score: lead.ai_agent_lead_score,
5398
+ ai_summary: lead.ai_summary ?? null,
5399
+ split_ai_summary: lead.split_ai_summary ?? null,
4522
5400
  location: lead.location,
4523
5401
  short_description: lead.short_description ?? lead.description,
4524
5402
  size: lead.size,
4525
5403
  website: lead.website,
5404
+ phone_numbers: lead.phone_numbers ?? null,
4526
5405
  tags: lead.tags,
4527
- recommended_contact_title: lead.recommended_contact_title ?? null,
4528
- recommended_contact: lead.recommended_contact ?? null,
5406
+ social_presence: lead.social_presence ?? null,
5407
+ social_urls: lead.social_urls ?? null,
5408
+ recommended_contact: augmentContact2(lead.recommended_contact),
4529
5409
  web_fetch_in_progress: lead.web_fetch_in_progress ?? false,
4530
5410
  enrichment_in_progress: lead.enrichment_in_progress ?? false,
4531
5411
  liked: lead.liked,
@@ -4560,7 +5440,182 @@ var pullLeads = {
4560
5440
  }
4561
5441
  };
4562
5442
 
5443
+ // ../core/dist/composite/pull-followups.js
5444
+ function normalizeLinkedinPage4(v) {
5445
+ if (v == null)
5446
+ return null;
5447
+ if (typeof v !== "string")
5448
+ return null;
5449
+ const trimmed = v.trim();
5450
+ if (!trimmed || trimmed.toLowerCase() === "null")
5451
+ return null;
5452
+ return trimmed;
5453
+ }
5454
+ function augmentContact(c) {
5455
+ if (!c)
5456
+ return null;
5457
+ return {
5458
+ ...c,
5459
+ linkedin_page: normalizeLinkedinPage4(c.linkedin_page ?? null)
5460
+ };
5461
+ }
5462
+ var pullFollowups = {
5463
+ name: "leadbay_pull_followups",
5464
+ annotations: {
5465
+ title: "Pull known leads to follow up on (Monitor view)",
5466
+ readOnlyHint: true,
5467
+ destructiveHint: false,
5468
+ idempotentHint: true,
5469
+ openWorldHint: true
5470
+ },
5471
+ description: leadbay_pull_followups,
5472
+ inputSchema: {
5473
+ type: "object",
5474
+ properties: {
5475
+ filtered: {
5476
+ type: "boolean",
5477
+ description: "Apply the user's stored Monitor filter (server-persisted via POST /monitor/filter). Default true."
5478
+ },
5479
+ personal: {
5480
+ type: "boolean",
5481
+ description: "When true, restrict to leads this user has personally monitored (not org-wide). Default false."
5482
+ },
5483
+ liked: {
5484
+ type: "boolean",
5485
+ description: "When true, restrict to leads the user has explicitly liked. Default false."
5486
+ },
5487
+ count: {
5488
+ type: "number",
5489
+ description: "Leads per page, max 200 (default 20)."
5490
+ },
5491
+ page: {
5492
+ type: "number",
5493
+ description: "Page number, 0-indexed (default 0)."
5494
+ },
5495
+ set_filter: {
5496
+ type: "object",
5497
+ description: "Optional FilterItem ({criteria: FilterCriterion[]}). When provided, the composite POSTs it to /monitor/filter (server-persists across sessions) BEFORE fetching the filtered Monitor view. Use to refine 'leads to follow up' by city, sector, recency, action type, etc.",
5498
+ properties: {
5499
+ criteria: {
5500
+ type: "array",
5501
+ description: "Array of FilterCriterion objects per the backend FilterCriterion anyOf schema (location_ids, sector_ids, size, keywords, last_action, last_action_date, liked, yc, custom_field, custom_field_comparison).",
5502
+ items: { type: "object" }
5503
+ }
5504
+ }
5505
+ }
5506
+ },
5507
+ additionalProperties: false
5508
+ },
5509
+ outputSchema: {
5510
+ type: "object",
5511
+ properties: {
5512
+ active_filters: {
5513
+ type: ["object", "null"],
5514
+ description: "The FilterItem currently stored server-side for this user (via GET /monitor/filter). null when no filter is set or when filtered:false was passed."
5515
+ },
5516
+ leads: {
5517
+ type: "array",
5518
+ description: "The page of monitored leads. Each lead carries the FullLead shape augmented with normalized linkedin_page on contacts and `recommended_contact`.",
5519
+ items: { type: "object" }
5520
+ },
5521
+ pagination: {
5522
+ type: ["object", "null"],
5523
+ description: "page / pages / total \u2014 the backend's pagination envelope when present."
5524
+ },
5525
+ total_excluded_by_pushback: {
5526
+ type: "number",
5527
+ 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."
5528
+ },
5529
+ _meta: {
5530
+ type: "object",
5531
+ description: "Operator context: region + last-call latency.",
5532
+ properties: {
5533
+ region: { type: "string" },
5534
+ latency_ms: { type: ["number", "null"] }
5535
+ }
5536
+ }
5537
+ },
5538
+ required: ["leads"]
5539
+ },
5540
+ execute: async (client, params, ctx) => {
5541
+ const filtered = params.filtered ?? true;
5542
+ const personal = params.personal ?? false;
5543
+ const liked = params.liked ?? false;
5544
+ const page = params.page ?? 0;
5545
+ const count = Math.min(params.count ?? 20, 200);
5546
+ if (params.set_filter) {
5547
+ try {
5548
+ await client.requestVoid("POST", "/monitor/filter", params.set_filter);
5549
+ } catch (err) {
5550
+ ctx?.logger?.warn?.(`pull_followups: POST /monitor/filter failed: ${err?.message ?? err?.code ?? err}`);
5551
+ }
5552
+ }
5553
+ const qs = new URLSearchParams({
5554
+ personal: String(personal),
5555
+ liked: String(liked),
5556
+ filtered: String(filtered),
5557
+ count: String(count),
5558
+ page: String(page)
5559
+ }).toString();
5560
+ const [filterR, monitorR] = await Promise.allSettled([
5561
+ filtered ? client.request("GET", "/monitor/filter") : Promise.resolve(null),
5562
+ client.request("GET", `/monitor?${qs}`)
5563
+ ]);
5564
+ const activeFilter = filterR.status === "fulfilled" ? filterR.value ?? null : null;
5565
+ if (monitorR.status === "rejected") {
5566
+ throw monitorR.reason;
5567
+ }
5568
+ const monitor = monitorR.value ?? {};
5569
+ const rawLeads = Array.isArray(monitor.items) ? monitor.items : Array.isArray(monitor.leads) ? monitor.leads : Array.isArray(monitor) ? monitor : [];
5570
+ const now = Date.now();
5571
+ const isActivePushback = (lead) => {
5572
+ const status = lead?.pushback_status;
5573
+ if (!status)
5574
+ return false;
5575
+ const until = lead?.pushback_until ?? lead?.pushback_status_set_at;
5576
+ if (!until)
5577
+ return true;
5578
+ const ts = Date.parse(until);
5579
+ if (Number.isNaN(ts))
5580
+ return true;
5581
+ return ts > now;
5582
+ };
5583
+ let excluded = 0;
5584
+ const leads = rawLeads.filter((lead) => {
5585
+ if (isActivePushback(lead)) {
5586
+ excluded += 1;
5587
+ return false;
5588
+ }
5589
+ return true;
5590
+ }).map((lead) => ({
5591
+ ...lead,
5592
+ recommended_contact: augmentContact(lead.recommended_contact),
5593
+ org_contacts: Array.isArray(lead.org_contacts) ? lead.org_contacts.map(augmentContact) : lead.org_contacts ?? null
5594
+ }));
5595
+ return {
5596
+ active_filters: activeFilter,
5597
+ leads,
5598
+ pagination: monitor.pagination ?? null,
5599
+ total_excluded_by_pushback: excluded,
5600
+ _meta: {
5601
+ region: client.region,
5602
+ latency_ms: client.lastMeta?.latency_ms ?? null
5603
+ }
5604
+ };
5605
+ }
5606
+ };
5607
+
4563
5608
  // ../core/dist/composite/research-lead.js
5609
+ function normalizeLinkedinPage5(v) {
5610
+ if (v == null)
5611
+ return null;
5612
+ if (typeof v !== "string")
5613
+ return null;
5614
+ const trimmed = v.trim();
5615
+ if (!trimmed || trimmed.toLowerCase() === "null")
5616
+ return null;
5617
+ return trimmed;
5618
+ }
4564
5619
  function renderResearchLeadMarkdown(shape) {
4565
5620
  const out = [];
4566
5621
  const firm = shape.firmographics ?? {};
@@ -4737,22 +5792,96 @@ var researchLead = {
4737
5792
  },
4738
5793
  firmographics: {
4739
5794
  type: "object",
4740
- description: "Lead profile basics. iter-30: nested additionalProperties:false closes the output-side strictness frontier \u2014 runtime returns must match exactly these keys.",
5795
+ description: "Lead profile basics. The shapes here match the backend `LeadSimplified` schema verbatim \u2014 `size` is `{min,max,...}`, `location` is `{city,state,country,full,pos}`, `tags` are typed objects.",
4741
5796
  properties: {
4742
5797
  id: { type: "string" },
4743
5798
  name: { type: "string" },
4744
5799
  sector_id: { type: ["number", "string", "null"] },
4745
- size: { type: ["string", "null"] },
4746
- location: { type: ["string", "null"] },
5800
+ size: {
5801
+ type: ["object", "null"],
5802
+ description: "LeadSimplified.size \u2014 employee-count band.",
5803
+ properties: {
5804
+ low: { type: ["number", "null"] },
5805
+ high: { type: ["number", "null"] },
5806
+ min: { type: ["number", "null"] },
5807
+ max: { type: ["number", "null"] },
5808
+ label: { type: ["string", "null"] }
5809
+ }
5810
+ },
5811
+ location: {
5812
+ type: ["object", "null"],
5813
+ description: "LeadFullLocation \u2014 city/state/country/full/pos.",
5814
+ properties: {
5815
+ city: { type: ["string", "null"] },
5816
+ state: { type: ["string", "null"] },
5817
+ country: { type: ["string", "null"] },
5818
+ full: { type: ["string", "null"] },
5819
+ pos: {
5820
+ type: ["array", "null"],
5821
+ items: { type: "number" }
5822
+ }
5823
+ }
5824
+ },
4747
5825
  website: { type: ["string", "null"] },
4748
5826
  description: { type: ["string", "null"] },
4749
5827
  short_description: { type: ["string", "null"] },
4750
- keywords: { type: "array", items: { type: "string" } },
4751
- tags: { type: "array", items: { type: "string" } },
5828
+ keywords: {
5829
+ type: "array",
5830
+ description: "Either bare strings (legacy) or {keyword,score} objects depending on the backend payload version.",
5831
+ items: {}
5832
+ },
5833
+ tags: {
5834
+ type: "array",
5835
+ description: "LeadTag[] \u2014 {id, display_name, tag, score}.",
5836
+ items: {
5837
+ type: "object",
5838
+ properties: {
5839
+ id: { type: ["number", "string", "null"] },
5840
+ display_name: { type: ["string", "null"] },
5841
+ tag: { type: "string" },
5842
+ score: { type: ["number", "null"] }
5843
+ }
5844
+ }
5845
+ },
4752
5846
  score: { type: ["number", "null"] },
4753
5847
  ai_agent_lead_score: { type: ["number", "null"] },
4754
- social_presence: { type: ["object", "string", "null"] },
4755
- social_urls: { type: ["object", "array", "null"] },
5848
+ ai_summary: { type: ["string", "null"] },
5849
+ split_ai_summary: {
5850
+ type: ["object", "null"],
5851
+ properties: {
5852
+ worth_pursuing: { type: ["string", "null"] },
5853
+ approach_angle: { type: ["string", "null"] },
5854
+ next_step: { type: ["string", "null"] }
5855
+ }
5856
+ },
5857
+ phone_numbers: {
5858
+ type: ["array", "null"],
5859
+ items: { type: "string" }
5860
+ },
5861
+ social_presence: {
5862
+ type: ["object", "null"],
5863
+ description: "LeadSocialPresence \u2014 6 booleans per platform. Use `social_urls` for URLs.",
5864
+ properties: {
5865
+ crunchbase: { type: "boolean" },
5866
+ facebook: { type: "boolean" },
5867
+ instagram: { type: "boolean" },
5868
+ linkedin: { type: "boolean" },
5869
+ tiktok: { type: "boolean" },
5870
+ twitter: { type: "boolean" }
5871
+ }
5872
+ },
5873
+ social_urls: {
5874
+ type: ["object", "null"],
5875
+ description: "LeadSocialUrls \u2014 URL strings per platform; null when the company has no profile.",
5876
+ properties: {
5877
+ crunchbase: { type: ["string", "null"] },
5878
+ facebook: { type: ["string", "null"] },
5879
+ instagram: { type: ["string", "null"] },
5880
+ linkedin: { type: ["string", "null"] },
5881
+ tiktok: { type: ["string", "null"] },
5882
+ twitter: { type: ["string", "null"] }
5883
+ }
5884
+ },
4756
5885
  registry_ids: { type: ["object", "array", "null"] }
4757
5886
  },
4758
5887
  additionalProperties: false
@@ -4773,7 +5902,6 @@ var researchLead = {
4773
5902
  liked: { type: "boolean" },
4774
5903
  disliked: { type: "boolean" },
4775
5904
  new: { type: "boolean" },
4776
- recommended_contact_title: { type: ["string", "null"] },
4777
5905
  recommended_contact: { type: ["object", "null"] },
4778
5906
  notes_count: { type: "number" },
4779
5907
  epilogue_actions_count: { type: "number" },
@@ -4894,11 +6022,16 @@ var researchLead = {
4894
6022
  tags: lead.tags,
4895
6023
  score: lead.score,
4896
6024
  ai_agent_lead_score: lead.ai_agent_lead_score,
6025
+ ai_summary: lead.ai_summary ?? null,
6026
+ split_ai_summary: lead.split_ai_summary ?? null,
6027
+ phone_numbers: lead.phone_numbers ?? null,
4897
6028
  social_presence: lead.social_presence ?? null,
4898
6029
  social_urls: lead.social_urls ?? null,
4899
6030
  registry_ids: lead.registry_ids ?? null
4900
6031
  },
4901
6032
  // 4) contacts (paid/enriched, plus org contacts if present)
6033
+ // B6: defensively coerce the literal string "null" to a real null —
6034
+ // some backend serializers emit it for un-enriched contacts.
4902
6035
  contacts: {
4903
6036
  enriched: paidContacts.map((c) => ({
4904
6037
  id: c.id,
@@ -4907,7 +6040,7 @@ var researchLead = {
4907
6040
  job_title: c.job_title,
4908
6041
  email: c.email,
4909
6042
  phone_number: c.phone_number,
4910
- linkedin_page: c.linkedin_page,
6043
+ linkedin_page: normalizeLinkedinPage5(c.linkedin_page),
4911
6044
  recommended: c.recommended,
4912
6045
  enrichment_done: c.enrichment?.done ?? false
4913
6046
  })),
@@ -4916,16 +6049,22 @@ var researchLead = {
4916
6049
  first_name: c.first_name,
4917
6050
  last_name: c.last_name,
4918
6051
  job_title: c.job_title,
4919
- email: c.email
6052
+ email: c.email,
6053
+ linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null)
4920
6054
  }))
4921
6055
  },
4922
- // 5) engagement — what humans/prior agent runs already did
6056
+ // 5) engagement — what humans/prior agent runs already did.
6057
+ // B8: `recommended_contact_title` dropped — it duplicates
6058
+ // `recommended_contact.job_title` and just confused agents.
4923
6059
  engagement: {
4924
6060
  liked: lead.liked,
4925
6061
  disliked: lead.disliked,
4926
6062
  new: lead.new ?? false,
4927
- recommended_contact_title: lead.recommended_contact_title ?? null,
4928
- recommended_contact: lead.recommended_contact ?? null,
6063
+ recommended_contact: lead.recommended_contact ? {
6064
+ ...lead.recommended_contact,
6065
+ // B1+B7: propagate linkedin_page when the backend includes it.
6066
+ linkedin_page: normalizeLinkedinPage5(lead.recommended_contact.linkedin_page ?? null)
6067
+ } : null,
4929
6068
  notes_count: lead.notes_count ?? 0,
4930
6069
  epilogue_actions_count: lead.epilogue_actions_count ?? 0,
4931
6070
  prospecting_actions_count: lead.prospecting_actions_count ?? 0,
@@ -8921,6 +10060,8 @@ var granularWriteTools = [
8921
10060
  dismissClarification,
8922
10061
  setEpilogueStatus,
8923
10062
  removeEpilogue,
10063
+ setPushback,
10064
+ removePushback,
8924
10065
  previewBulkEnrichment,
8925
10066
  launchBulkEnrichment,
8926
10067
  createCustomField
@@ -8935,6 +10076,7 @@ granularTools.forEach((t) => {
8935
10076
  });
8936
10077
  var compositeReadTools = [
8937
10078
  pullLeads,
10079
+ pullFollowups,
8938
10080
  researchLead,
8939
10081
  recallOrderedTitles,
8940
10082
  accountStatus,
@@ -9019,12 +10161,15 @@ export {
9019
10161
  dismissClarification,
9020
10162
  setEpilogueStatus,
9021
10163
  removeEpilogue,
10164
+ setPushback,
10165
+ removePushback,
9022
10166
  previewBulkEnrichment,
9023
10167
  launchBulkEnrichment,
9024
10168
  createCustomField,
9025
10169
  researchCompany,
9026
10170
  prepareOutreach,
9027
10171
  pullLeads,
10172
+ pullFollowups,
9028
10173
  researchLead,
9029
10174
  recallOrderedTitles,
9030
10175
  accountStatus,