@leadbay/mcp 0.8.0 → 0.9.1

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.
@@ -580,6 +580,43 @@ WHEN TO USE: when the user wants more qualified leads than what's currently show
580
580
  WHEN NOT TO USE: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).
581
581
 
582
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) |
583
620
  `;
584
621
  var leadbay_clear_selection = `Clear the user's transient selection.
585
622
 
@@ -761,6 +798,38 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
761
798
 
762
799
 
763
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) |
764
833
  `;
765
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.
766
835
 
@@ -776,12 +845,76 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
776
845
 
777
846
 
778
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) |
779
880
  `;
780
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.
781
882
 
782
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.
783
884
 
784
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 |
785
918
  `;
786
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.
787
920
 
@@ -805,7 +938,35 @@ Optional \`for_records\` param: pass a sample of CSV-shaped rows and the tool al
805
938
 
806
939
  WHEN TO USE: before authoring an import mapping, especially when the CSV has columns that aren't obvious matches for standard fields.
807
940
 
808
- 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) |
809
970
  `;
810
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.
811
972
 
@@ -829,11 +990,110 @@ WHEN NOT TO USE: from agent flow \u2014 use leadbay_answer_clarification.
829
990
 
830
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\`.
831
992
  `;
832
- 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\`.
833
996
 
834
- 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.
835
1003
 
836
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.
837
1097
  `;
838
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.
839
1099
 
@@ -849,15 +1109,253 @@ WHEN NOT TO USE: as a non-admin (will fail with 403); for personal lens changes
849
1109
 
850
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\`.
851
1111
  `;
852
- 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 50\u2013200 employees" | \`{type: "size", sizes: [{min: 50, max: 200}]}\` |
1127
+ | "Y Combinator companies" | \`{type: "yc"}\` |
1128
+
1129
+ Geo filtering requires \`admin_area_id\` resolution (the backend doesn't accept free-text city names in \`location_ids\`). The MCP doesn't expose an admin-area lookup yet \u2014 for now, ask the user to pick the geo from the Leadbay app's filter UI, or skip the geo filter and rely on agent post-filtering of the response.
1130
+
1131
+ **Pushback exclusion.** Leads with active pushback (\`pushback_status\` set and \`pushback_until > today\`) are excluded from the response. The composite enforces this client-side; \`total_excluded_by_pushback\` in the output reports how many rows were dropped.
1132
+
1133
+ WHEN TO USE: re-engaging pipeline ("what should I follow up on", "stale leads"), filtering monitored leads by city / sector / recency / action type / liked. The canonical orchestrator is the \`leadbay_followup_check_in\` prompt.
1134
+
1135
+ WHEN NOT TO USE: for NEW leads \u2014 that's \`leadbay_pull_leads\` (Discover).
1136
+
1137
+ **Anti-confusion guardrail.** If you're iterating \`pull_leads\` pages looking for rows with \`prospecting_actions_count > 0\` or \`notes_count > 0\`, STOP \u2014 wrong entry point. The two read different backend tables; a lead with follow-up history may not appear in \`pull_leads\` (already aged out of the new-leads queue). Call \`pull_followups\` instead.
1138
+
1139
+ ---
1140
+
1141
+ ## RENDERING \u2014 follow-ups table, status-badge driven
1142
+
1143
+ 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.
1144
+
1145
+ **Active-filters line** ABOVE the table, \` \xB7 \`-separated chips from \`active_filters.criteria\`:
1146
+
1147
+ | Criterion type | Chip |
1148
+ |-----------------------|----------------------------|
1149
+ | \`location_ids\` | \u{1F4CD} \\<resolved name\\> |
1150
+ | \`sector_ids\` | \u{1F3F7} \\<sector name\\> |
1151
+ | \`keywords\` | \u{1F50D} \\<keyword\\> |
1152
+ | \`size\` | \u{1F465} \\<min\\>\u2013\\<max\\> |
1153
+ | \`last_action_date\` | \u{1F4C5} \\<window\\> |
1154
+ | \`last_action\` | \u{1F3AF} \\<action types\\> |
1155
+ | \`liked\` / \`yc\` | \u2B50 liked / \u{1F3C5} YC |
1156
+ | \`custom_field*\` | \u2699 \\<field name\\> |
1157
+
1158
+ Render \`*No filters applied.*\` when empty.
1159
+
1160
+ **Column 1 \u2014 Status** (DERIVED from existing fields, priority order):
1161
+
1162
+ 1. \`epilogue_status == "EPILOGUE_INTEREST_VALIDATED_OR_MEETING_PLANED"\` \u2192 \u{1F3AF} Meeting booked
1163
+ 2. \`epilogue_status == "EPILOGUE_COULD_NOT_REACH_STILL_TRYING"\` \u2192 \u26A1 Trying to reach
1164
+ 3. \`epilogue_status == "EPILOGUE_STILL_CHASING"\` + last_prospecting_action_at within 14d \u2192 \u{1F7E2} In progress
1165
+ 4. \`epilogue_status == "EPILOGUE_NOT_INTERESTED_LOST"\` \u2192 \u2744 Not interested (usually filtered out)
1166
+ 5. \`epilogue_status == null\` + \`last_prospecting_action_at == null\` \u2192 \u2728 New
1167
+ 6. \`epilogue_status == null\` + \`last_prospecting_action_at > 60d\` \u2192 \u{1F4A4} Dormant
1168
+ 7. Otherwise \u2192 \u{1F525} Hot
1169
+
1170
+ 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.
1171
+
1172
+ **Column 2 \u2014 AI take** (3 lines from \`split_ai_summary\` verbatim, \`<br>\`-separated):
1173
+
1174
+ - 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}.
1175
+ - Line 2: *italic split_ai_summary.approach_angle* (\u2264 18 words).
1176
+ - Line 3: **Next:** split_ai_summary.next_step.
1177
+ - Fallback when null: render \`ai_summary\` italic, no emoji.
1178
+
1179
+ **Column 3 \u2014 History & notes**
1180
+
1181
+ - Line 1: \`<relative time> ago \xB7 <last_prospecting_action>\` (humanize: snake_case \u2192 Title Case; drop \`LEAD_\` prefix). When null: \`*Never touched.*\`
1182
+ - Line 2 (when \`epilogue_status\` set): \`\u{1F4CC} Outcome: <user-facing label> \xB7 <relative time>\` \u2014 NEVER show the wire-format \`EPILOGUE_*\` value.
1183
+ - Line 3 (when a recent note surfaces): \`\u{1F4DD} *<note clipped \u2264 14 words>* (<rel time>)\`.
1184
+ - If neither: \`*0 prior touches \xB7 0 notes*\`.
1185
+
1186
+ **Column 4 \u2014 Contacts** (max 3 lines, recommended_contact first):
1187
+
1188
+ \`\`\`
1189
+ \u2605 [Name](linkedin_page or people-search) \xB7 \u260E phone \xB7 \u{1F4E7} email
1190
+ \`\`\`
1191
+
1192
+ 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.
1193
+
1194
+ **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"\`.
1195
+
1196
+ **Legend** (user-facing \u2014 NEVER say "epilogue"):
1197
+
1198
+ \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
1199
+
1200
+ ## Linking a contact's name
1201
+
1202
+ Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
1203
+
1204
+ 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.
1205
+
1206
+ Otherwise fall back to a LinkedIn people-search URL:
1207
+
1208
+ \`\`\`
1209
+ https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1210
+ \`\`\`
1211
+
1212
+ 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.
1213
+
1214
+ 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.
1215
+
1216
+ ## Linking the company
1217
+
1218
+ 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.)
1219
+
1220
+ 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.
1221
+
1222
+ \`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).
1223
+
1224
+
1225
+
1226
+ ---
1227
+
1228
+ ## NEXT STEPS \u2014 after the follow-ups table
1229
+
1230
+ Always include at least one filter-modification offer (users think in filters: by city, by recency, by action type). Filter modification goes through \`set_filter: FilterItem\` which the composite POSTs to \`/monitor/filter\` server-side.
1231
+
1232
+ | Observation | Suggest | Calls |
1233
+ |-----------------------------------------------|----------------------------------------------------------|----------------------------------------------------------------------------------------------------|
1234
+ | Always (top of menu) | "Prep outreach for [top row's contact]" | leadbay_prepare_outreach(leadId) |
1235
+ | User named a city / sector / timeframe | "Refilter by [their phrase]" | leadbay_pull_followups(set_filter: { criteria: [...] }) |
1236
+ | \`pagination.has_more == true\` | "Pull the next page" | leadbay_pull_followups(page = current + 1) |
1237
+ | \u22653 rows \u2728 (never-touched) | "Surface only never-touched leads" | set_filter with \`last_action_date.last_days = 0\` |
1238
+ | \u22653 rows \u26A1 (Trying to reach) | "Focus on overdue commitments" | set_filter with \`last_action.types = ["EPILOGUE_COULD_NOT_REACH_STILL_TRYING"]\` |
1239
+ | User planning a trip / in a city | "Group by city for trip planning" | set_filter with \`location_ids\` (requires admin_area_id) |
1240
+ | All rows last action > 60d | "Re-qualify \u2014 context may have changed" | leadbay_bulk_qualify_leads([leadId, ...]) |
1241
+ | One obvious priority row | "Take me to that lead's full brief" | leadbay_prepare_outreach(leadId) / leadbay_research_lead(leadId) |
1242
+ | User wants to defer a lead | "Snooze [Company] for 3 / 6 / 12 months" | leadbay_set_pushback({ lead_ids:[leadId], status:"3" }) |
1243
+ | User completed outreach mid-flow | "Log the outreach + record the outcome" | leadbay_report_outreach |
1244
+ | Discovery mode might fit better | "Looking for NEW leads instead? Switch to discovery." | leadbay_pull_leads |
1245
+
1246
+ Always offer at least one of: prep outreach, refilter, pushback. Pushback is the canonical way to honor "not now" / "next quarter" \u2014 leads with active pushback are excluded from this view until expiry.
1247
+ `;
1248
+ var leadbay_pull_leads = `Pull up new leads from the user's last-active lens \u2014 the canonical "show me today's prospects" tool. Leadbay works like an inbox: each time the user logs back in, a fresh batch is delivered, paced by how many leads they've actually acted on recently. Pulling more won't produce more; user outreach/skips/saves does. Each returned lead carries a one-line \`qualification_summary\` built from leadbay_ai_agent_responses, plus the rich tags / scores / engagement counters / in-flight flags from the lead summary.
853
1249
 
854
- 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.
1250
+ Roughly the top 10 of the batch come pre-qualified (populated qualification_summary + ai_agent_lead_score); leads below the top ~10 carry only the basic firmographic \`score\` \u2014 not worse, just resource-saved by the system. Call leadbay_bulk_qualify_leads to deepen any of them on demand \u2014 a healthy daily rhythm is to bulk-qualify the rows without \u2756 caps so tomorrow's top-10 list is richer.
1251
+
1252
+ 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.
855
1253
 
856
1254
  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.
857
1255
 
858
1256
  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).
859
1257
 
860
- 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 \`lensId\` from the first response and pass it explicitly to every subsequent Leadbay call** \u2014 including re-pulls, bulk qualifies, and research. Re-pulling without \`lensId\` after a long-running tool may silently switch to a different lens and discard prior work.
1258
+ 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.
1259
+
1260
+ ---
1261
+
1262
+ ## RENDERING \u2014 markdown table, three columns, score-bar driven
1263
+
1264
+ 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.
1265
+
1266
+ ## Score-bar (10-segment, inline-code wrapped)
1267
+
1268
+ 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.
1269
+
1270
+ Glyphs (use these exact characters; do not substitute):
1271
+
1272
+ - \`\u25B0\` \u2014 firmographic-only fill
1273
+ - \`\u2756\` \u2014 AI-booster cap (placed at the RIGHT END of the filled run, never the front)
1274
+ - \`\u25B1\` \u2014 empty
1275
+
1276
+ Computation:
1277
+
1278
+ \`\`\`
1279
+ total_filled = round(score / 10), clamped to 0..10
1280
+ ai_segments = round(qualification_summary.avg_qualification_boost / 3.3),
1281
+ clamped to [0, total_filled]
1282
+ normal_filled = total_filled \u2212 ai_segments
1283
+ bar = "\u25B0" \xD7 normal_filled
1284
+ + "\u2756" \xD7 ai_segments
1285
+ + "\u25B1" \xD7 (10 \u2212 total_filled)
1286
+ \`\`\`
1287
+
1288
+ 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.
1289
+
1290
+
1291
+ **Column 1 \u2014 Company**
1292
+
1293
+ - Line 1: the 10-segment score bar in inline-code backticks (see the score-bar snippet above for the algorithm).
1294
+ - Insert \`<br>\` between lines.
1295
+ - Line 2: linked company name + \` \xB7 \` + short location + \` \xB7 \` + compact size.
1296
+ - Link target: \`website\` (prefix \`https://\` if it's a bare hostname). Don't synthesize an app deep-link.
1297
+ - Location: shorten "City of New York" \u2192 "NYC"; otherwise "City ST"; state alone only when city missing.
1298
+ - Size: \`"Xk+"\` when \`size.min >= 1000\`, \`"min\u2013max"\` otherwise.
1299
+
1300
+ **Column 2 \u2014 Why it fits**
1301
+
1302
+ - One sentence, \u2264 20 words.
1303
+ - Synthesize from (in priority order, whichever is present) the lead's \`short_description\`, top 2 \`tags[].display_name\`, and the gist of \`qualification_summary.best_response_excerpt\`. The trim payload does NOT carry the longer \`description\` field \u2014 for that, agent must call \`leadbay_research_lead\` or \`leadbay_research_company\`.
1304
+ - Do NOT append \`(boost N)\` \u2014 the \u2756 cap in column 1 already carries that signal.
1305
+ - No bullet lists, no line breaks inside the cell.
1306
+
1307
+ **Column 3 \u2014 Contact**
1308
+
1309
+ \`[Contact name](LINK) \xB7 short job title\`. See linking/contact-linkedin for LINK priority and the \xB0-flag fallback.
1310
+
1311
+ **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"\`.
1312
+
1313
+ ## Linking a contact's name
1314
+
1315
+ Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
1316
+
1317
+ 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.
1318
+
1319
+ Otherwise fall back to a LinkedIn people-search URL:
1320
+
1321
+ \`\`\`
1322
+ https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1323
+ \`\`\`
1324
+
1325
+ 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.
1326
+
1327
+ 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.
1328
+
1329
+ ## Linking the company
1330
+
1331
+ 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.)
1332
+
1333
+ 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.
1334
+
1335
+ \`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).
1336
+
1337
+
1338
+
1339
+ ---
1340
+
1341
+ ## NEXT STEPS \u2014 after rendering the pull_leads table
1342
+
1343
+ Pick 2\u20133 items below based on what was actually observed in the response. Surface them as a short bulleted list \u2014 do NOT recite the whole table.
1344
+
1345
+ | Observation | Suggest | Calls |
1346
+ |------------------------------------------------------------|--------------------------------------------------------------|--------------------------------------------------------|
1347
+ | \`has_more == true\` | "Pull the next page (page N+1 of M)" | leadbay_pull_leads(page = current + 1, lensId = pinned)|
1348
+ | \u2265 3 rows have \`qualification_summary.answered == 0\` | "Deepen AI qualification on the rows without \u2756 caps" | leadbay_bulk_qualify_leads(leadIds=[\u2026]) |
1349
+ | User points at a single row | "Research [Company] in depth" | leadbay_research_lead(leadId) |
1350
+ | User wants the company-level web view | "Pull the company-level research for [Company]" | leadbay_research_company({leadId} or {companyName}) |
1351
+ | Top row has phone AND email | "Prepare an outreach for [Contact] \u2014 call + email" | leadbay_prepare_outreach(leadId) |
1352
+ | Top row has email but no phone | "Draft an outreach email for [Contact]" | leadbay_prepare_outreach(leadId) |
1353
+ | Top row has phone but no email | "Show [Contact]'s call details + a 60-second opener" | leadbay_prepare_outreach(leadId) |
1354
+ | 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) |
1355
+ | \`computing_scores == true\` or \`computing_wishlist == true\` | "Scores are still being computed \u2014 re-pull in ~30s" | leadbay_pull_leads (retry with same lensId) |
1356
+ | User wants a narrower / wider audience | "Adjust the lens filters (sector / size)" | leadbay_adjust_audience(...) |
1357
+
1358
+ 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.
861
1359
  `;
862
1360
  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.
863
1361
 
@@ -893,6 +1391,16 @@ WHEN TO USE: when an outreach action was logged in error and needs to be undone.
893
1391
 
894
1392
  WHEN NOT TO USE: to change status \u2014 call leadbay_set_epilogue_status with the new status (it overwrites).
895
1393
 
1394
+ 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\`.
1395
+ `;
1396
+ 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).
1397
+
1398
+ 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).
1399
+
1400
+ WHEN TO USE: the user wants to revive a snoozed lead now, ahead of the pushback window expiry.
1401
+
1402
+ WHEN NOT TO USE: the pushback expired naturally (no API call needed; the lead reappears in \`pull_followups\` automatically).
1403
+
896
1404
  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\`.
897
1405
  `;
898
1406
  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).
@@ -907,9 +1415,120 @@ This tool MUTATES state. The caller (agent or human-in-the-loop) is responsible
907
1415
  `;
908
1416
  var leadbay_research_company = `Deep-dive research on a specific company by NAME (fuzzy match against the active lens's wishlist). Pass \`companyName\` (matches the top-scoring lead with that name) or \`leadId\` (takes precedence when both supplied).
909
1417
 
910
- WHEN TO USE: when the user references a company by name and you don't yet have its \`lead_id\`.
1418
+ The response carries the rich-lead block (firmographics, \`phone_numbers\`, \`split_ai_summary\`, \`social_urls\`), \`qualification[]\` (Q&A pairs from the AI agent \u2014 empty until the lead is qualified), \`contacts[]\` (paid + org, each with a normalized \`linkedin_page\`), \`web_insights\` (keyed by emoji-prefixed section labels \u2014 see RENDERING for handling), \`web_insights_fetched_at\` (staleness), and \`recent_activities\` (engagement history).
1419
+
1420
+ WHEN TO USE: when the user references a company by name and you don't yet have its \`lead_id\`, OR when you want a single-record card with web-research signals (business signals, prospecting clues, strategic positioning) rather than a raw lead profile.
1421
+
1422
+ WHEN NOT TO USE: when you already have the lead_id and need the bundled deeper lens-scoped data \u2014 use leadbay_research_lead.
1423
+
1424
+ ---
1425
+
1426
+ ## RENDERING \u2014 single-record research card, mode-adaptive
1427
+
1428
+ Present as a single-record card, not a table. This tool gets invoked in two distinct user contexts \u2014 detect which and adapt the body density accordingly.
1429
+
1430
+ **MODE A \u2014 Discovery.** The user is evaluating whether to pursue this company as a target. Signals: "tell me about", "what do they do", "is this a fit", "research [company]", arrival via a click-through from \`leadbay_pull_leads\`, no prior outreach context in the conversation. Next step is usually qualify, deep-dive via \`leadbay_research_lead\`, or decide whether to start outreach.
1431
+
1432
+ **MODE B \u2014 Contact preparation.** The user is about to call or email someone at this company and needs the talking points. Signals: "I'm calling them", "draft an email", "before my call", "outreach prep", "what should I say", or the conversation has already touched on a specific contact. Next step is usually \`leadbay_prepare_outreach\`.
1433
+
1434
+ Default to MODE A when uncertain. Always offer the cross-mode pivot at the end so the user can redirect if you guessed wrong.
1435
+
1436
+ ### Common structure (both modes)
1437
+
1438
+ - **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.
1439
+ - **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.
1440
+ - **Blurb**: render \`description\` (preferred) or \`short_description\` as a single blockquoted paragraph.
1441
+ - **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.
1442
+ - **Contacts table** (always at the bottom):
1443
+ \`\`\`
1444
+ | | Name | Title | LinkedIn |
1445
+ \`\`\`
1446
+ Markers in column 1:
1447
+ - \`\u2605\` \u2014 \`recommended_contact\` match.
1448
+ - \`\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.)
1449
+ 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"\`.
1450
+
1451
+ ### MODE A body (Discovery, fuller, scannable)
1452
+
1453
+ 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).
1454
+
1455
+ ### MODE B body (Contact preparation, tighter)
1456
+
1457
+ Render exactly two H5 sections:
1458
+
1459
+ ##### \u{1F3AF} Conversation hooks
911
1460
 
912
- 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).
1461
+ 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.
1462
+
1463
+ ##### \u{1F464} About the person *(only when recommended_contact is non-empty)*
1464
+
1465
+ 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.
1466
+
1467
+ 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.
1468
+
1469
+ If \`qualification[]\` is non-empty, append one collapsed line: \`"Qualification: N questions answered, avg boost X"\` and offer to expand in NEXT STEPS.
1470
+
1471
+ **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.
1472
+
1473
+ **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) \`\`
1474
+
1475
+ ## Linking a contact's name
1476
+
1477
+ Two LinkedIn URLs exist and must never be conflated: the **company's** LinkedIn page and an **individual person's** profile.
1478
+
1479
+ 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.
1480
+
1481
+ Otherwise fall back to a LinkedIn people-search URL:
1482
+
1483
+ \`\`\`
1484
+ https://www.linkedin.com/search/results/people/?keywords=<First>+<Last>+<Company>
1485
+ \`\`\`
1486
+
1487
+ 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.
1488
+
1489
+ 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.
1490
+
1491
+ ## Linking the company
1492
+
1493
+ 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.)
1494
+
1495
+ 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.
1496
+
1497
+ \`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).
1498
+
1499
+
1500
+
1501
+ ---
1502
+
1503
+ ## NEXT STEPS \u2014 after the research card
1504
+
1505
+ Offer 2\u20133 follow-ups that match the detected mode. Always offer a cross-mode pivot at the end so the user can redirect if you guessed wrong.
1506
+
1507
+ ### MODE A (Discovery)
1508
+
1509
+ | Observation | Suggest | Calls |
1510
+ |--------------------------------------------------------|----------------------------------------------------|----------------------------------------------------|
1511
+ | \`qualification[]\` is empty | "Run AI qualification on this lead" | leadbay_bulk_qualify_leads([leadId]) |
1512
+ | \u22651 hot recent item in \u{1F4C8} business signals | "Prepare outreach referencing [signal headline]" | leadbay_prepare_outreach(leadId) |
1513
+ | \`contacts_count > len(contacts)\` shown | "Pull the full contact list (N more)" | leadbay_get_contacts(leadId) |
1514
+ | \`web_insights_fetched_at\` > 30 days | "Re-run the web research \u2014 this is stale" | leadbay_research_company(leadId) \u2014 refresh |
1515
+ | User wants the deeper lens-scoped bundle | "Pull the full lead profile (research_lead)" | leadbay_research_lead(leadId) |
1516
+ | User is exploring multiple companies | "Back to the lead list" | leadbay_pull_leads |
1517
+ | \`qualification[]\` non-empty | "Expand the AI qualification answers" | (render qualification[] as a sub-card) |
1518
+
1519
+ End MODE A with the pivot offer: \`"Want the contact-prep view for [recommended contact name]?"\`
1520
+
1521
+ ### MODE B (Contact preparation)
1522
+
1523
+ | Observation | Suggest | Calls |
1524
+ |--------------------------------------------------------|----------------------------------------------------|----------------------------------------------------|
1525
+ | \`phone_numbers[]\` non-empty | "Show full call notes + a 60-second opener" | leadbay_prepare_outreach(leadId) |
1526
+ | Recommended contact has an email | "Draft the outreach email" | leadbay_prepare_outreach(leadId) |
1527
+ | Neither phone nor email for recommended contact | "Order contact enrichment first" | leadbay_prepare_outreach(leadId, enrich:true) or leadbay_enrich_titles |
1528
+ | After the user reports a touchpoint | "Log the call/email outcome" | leadbay_report_outreach |
1529
+ | Adding pre-call context | "Add a note to this lead" | leadbay_add_note |
1530
+
1531
+ End MODE B with the pivot offer: \`"Want the full strategic overview instead?"\`
913
1532
  `;
914
1533
  var leadbay_research_lead = `Tell me everything decision-relevant about a single lead. Bundles the lens-scoped lead profile, the AI qualification answers (the agent's knowledge-base food), the structured web-research signals (with hot flags + sources), the enriched contacts, and the recent notes/epilogue/prospecting activity in one call. Order is deliberate: qualification first, then signals, then firmographics, then contacts, then engagement.
915
1534
 
@@ -926,6 +1545,38 @@ var leadbay_resolve_import_rows = `Resolve messy CSV-shaped lead rows against Le
926
1545
  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.
927
1546
 
928
1547
  WHEN NOT TO USE: for prospect discovery from scratch (use leadbay_pull_leads); for one known company profile (use leadbay_research_company / leadbay_research_lead); or when the file already has clean, final LEADBAY_ID/CRM_ID/SIREN mappings and no row-level identity disambiguation is needed.
1548
+
1549
+ ---
1550
+
1551
+ ## RENDERING
1552
+
1553
+ Present as a markdown table of resolution outcomes per row.
1554
+
1555
+ \`\`\`
1556
+ | Source row | Match status | Resolved leadId / candidates |
1557
+ \`\`\`
1558
+
1559
+ Status emoji map:
1560
+
1561
+ - \`\u2713\` unambiguous match \u2014 one Leadbay lead, high confidence
1562
+ - \`\u26A0\` ambiguous \u2014 multiple candidates returned
1563
+ - \`\u2717\` no match \u2014 backend has no candidates; can still import if \`website\` is present
1564
+ - \`\u23F3\` resolving \u2014 async resolve still running
1565
+
1566
+ 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).
1567
+
1568
+ Below the table, a one-liner: \`"Ready: K rows \xB7 Ambiguous: A rows \xB7 Unmatched: U rows"\`
1569
+
1570
+ ---
1571
+
1572
+ ## NEXT STEPS
1573
+
1574
+ | Observation | Suggest | Calls |
1575
+ |----------------------------------------|-------------------------------------------------------------|--------------------------------------------------------|
1576
+ | All rows resolved cleanly | "Import these rows now" | leadbay_import_leads(records_for_import, mappings_for_import) |
1577
+ | Ambiguous rows present | "Inspect candidates for each ambiguous row" | (re-call with include_candidate_profiles=true) |
1578
+ | Unmatched rows but websites present | "Import anyway \u2014 Leadbay will crawl and match later" | leadbay_import_leads (status check after) |
1579
+ | User wants to skip rows they can't ID | "Drop unmatched rows and import the rest" | leadbay_import_leads (with filtered records) |
929
1580
  `;
930
1581
  var leadbay_select_leads = `Add leads to the user's transient selection (used by selection-scoped bulk operations). Accepts 1-1000 \`leadIds\` per call.
931
1582
 
@@ -949,6 +1600,18 @@ WHEN TO USE: low-level.
949
1600
 
950
1601
  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.
951
1602
 
1603
+ 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\`.
1604
+ `;
1605
+ 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.
1606
+
1607
+ 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.
1608
+
1609
+ 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".
1610
+
1611
+ 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.
1612
+
1613
+ 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.
1614
+
952
1615
  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\`.
953
1616
  `;
954
1617
  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.
@@ -1140,6 +1803,16 @@ var discoverLeads = {
1140
1803
  };
1141
1804
 
1142
1805
  // ../core/dist/tools/get-lead-profile.js
1806
+ function normalizeLinkedinPage(v) {
1807
+ if (v == null)
1808
+ return null;
1809
+ if (typeof v !== "string")
1810
+ return null;
1811
+ const trimmed = v.trim();
1812
+ if (!trimmed || trimmed.toLowerCase() === "null")
1813
+ return null;
1814
+ return trimmed;
1815
+ }
1143
1816
  var getLeadProfile = {
1144
1817
  name: "leadbay_get_lead_profile",
1145
1818
  annotations: {
@@ -1216,7 +1889,7 @@ var getLeadProfile = {
1216
1889
  last_name: c.last_name,
1217
1890
  email: c.email,
1218
1891
  phone_number: c.phone_number,
1219
- linkedin_page: c.linkedin_page,
1892
+ linkedin_page: normalizeLinkedinPage(c.linkedin_page),
1220
1893
  job_title: c.job_title,
1221
1894
  recommended: c.recommended,
1222
1895
  enrichment: c.enrichment,
@@ -1228,7 +1901,7 @@ var getLeadProfile = {
1228
1901
  last_name: c.last_name,
1229
1902
  email: c.email,
1230
1903
  phone_number: c.phone_number,
1231
- linkedin_page: c.linkedin_page,
1904
+ linkedin_page: normalizeLinkedinPage(c.linkedin_page),
1232
1905
  job_title: c.job_title,
1233
1906
  recommended: c.recommended,
1234
1907
  enrichment: c.enrichment,
@@ -1253,8 +1926,14 @@ var getLeadProfile = {
1253
1926
  phone_numbers: lead.phone_numbers,
1254
1927
  keywords: lead.keywords,
1255
1928
  contacts_count: lead.contacts_count,
1256
- recommended_contact_title: lead.recommended_contact_title ?? null,
1257
- recommended_contact: lead.recommended_contact ?? null,
1929
+ // B8: recommended_contact_title dropped — duplicates
1930
+ // recommended_contact.job_title. B1+B7: propagate linkedin_page.
1931
+ recommended_contact: lead.recommended_contact ? {
1932
+ ...lead.recommended_contact,
1933
+ linkedin_page: normalizeLinkedinPage(lead.recommended_contact.linkedin_page ?? null)
1934
+ } : null,
1935
+ social_presence: lead.social_presence ?? null,
1936
+ social_urls: lead.social_urls ?? null,
1258
1937
  web_fetch_in_progress: lead.web_fetch_in_progress ?? false
1259
1938
  },
1260
1939
  qualification: qualification?.map((q) => ({
@@ -4036,6 +4715,100 @@ var removeEpilogue = {
4036
4715
  }
4037
4716
  };
4038
4717
 
4718
+ // ../core/dist/tools/set-pushback.js
4719
+ var PUSHBACK_LABEL_MAP = {
4720
+ "3": "PUSHBACK_3",
4721
+ "6": "PUSHBACK_6",
4722
+ "12": "PUSHBACK_12",
4723
+ "3m": "PUSHBACK_3",
4724
+ "6m": "PUSHBACK_6",
4725
+ "12m": "PUSHBACK_12",
4726
+ "3_months": "PUSHBACK_3",
4727
+ "6_months": "PUSHBACK_6",
4728
+ "12_months": "PUSHBACK_12",
4729
+ PUSHBACK_3: "PUSHBACK_3",
4730
+ PUSHBACK_6: "PUSHBACK_6",
4731
+ PUSHBACK_12: "PUSHBACK_12"
4732
+ };
4733
+ var setPushback = {
4734
+ name: "leadbay_set_pushback",
4735
+ annotations: {
4736
+ title: "Pushback (snooze) leads for 3 / 6 / 12 months",
4737
+ readOnlyHint: false,
4738
+ destructiveHint: true,
4739
+ idempotentHint: true,
4740
+ openWorldHint: true
4741
+ },
4742
+ description: leadbay_set_pushback,
4743
+ optional: true,
4744
+ write: true,
4745
+ inputSchema: {
4746
+ type: "object",
4747
+ properties: {
4748
+ lead_ids: {
4749
+ type: "array",
4750
+ items: { type: "string" },
4751
+ description: "Lead UUIDs (1-1000)"
4752
+ },
4753
+ status: {
4754
+ type: "string",
4755
+ description: "One of: 3, 6, 12 (months) \u2014 or the long form PUSHBACK_3 / PUSHBACK_6 / PUSHBACK_12."
4756
+ }
4757
+ },
4758
+ required: ["lead_ids", "status"],
4759
+ additionalProperties: false
4760
+ },
4761
+ execute: async (client, params) => {
4762
+ const wire = PUSHBACK_LABEL_MAP[String(params.status)];
4763
+ if (!wire) {
4764
+ return {
4765
+ error: true,
4766
+ code: "BAD_INPUT",
4767
+ message: `Unknown pushback status: ${params.status}`,
4768
+ hint: "Use one of: 3, 6, 12 (months) \u2014 or PUSHBACK_3 / PUSHBACK_6 / PUSHBACK_12."
4769
+ };
4770
+ }
4771
+ await client.requestVoid("POST", "/leads/pushback", {
4772
+ lead_ids: params.lead_ids,
4773
+ status: wire
4774
+ });
4775
+ return { applied: true, count: params.lead_ids.length, status: wire };
4776
+ }
4777
+ };
4778
+
4779
+ // ../core/dist/tools/remove-pushback.js
4780
+ var removePushback = {
4781
+ name: "leadbay_remove_pushback",
4782
+ annotations: {
4783
+ title: "Remove pushback (un-snooze) leads",
4784
+ readOnlyHint: false,
4785
+ destructiveHint: true,
4786
+ idempotentHint: true,
4787
+ openWorldHint: true
4788
+ },
4789
+ description: leadbay_remove_pushback,
4790
+ optional: true,
4791
+ write: true,
4792
+ inputSchema: {
4793
+ type: "object",
4794
+ properties: {
4795
+ lead_ids: {
4796
+ type: "array",
4797
+ items: { type: "string" },
4798
+ description: "Lead UUIDs"
4799
+ }
4800
+ },
4801
+ required: ["lead_ids"],
4802
+ additionalProperties: false
4803
+ },
4804
+ execute: async (client, params) => {
4805
+ await client.requestVoid("POST", "/leads/remove_pushback", {
4806
+ lead_ids: params.lead_ids
4807
+ });
4808
+ return { cleared: true, count: params.lead_ids.length };
4809
+ }
4810
+ };
4811
+
4039
4812
  // ../core/dist/tools/preview-bulk-enrichment.js
4040
4813
  var previewBulkEnrichment = {
4041
4814
  name: "leadbay_preview_bulk_enrichment",
@@ -4306,6 +5079,16 @@ var researchCompany = {
4306
5079
  };
4307
5080
 
4308
5081
  // ../core/dist/composite/prepare-outreach.js
5082
+ function normalizeLinkedinPage2(v) {
5083
+ if (v == null)
5084
+ return null;
5085
+ if (typeof v !== "string")
5086
+ return null;
5087
+ const trimmed = v.trim();
5088
+ if (!trimmed || trimmed.toLowerCase() === "null")
5089
+ return null;
5090
+ return trimmed;
5091
+ }
4309
5092
  var prepareOutreach = {
4310
5093
  name: "leadbay_prepare_outreach",
4311
5094
  annotations: {
@@ -4326,7 +5109,7 @@ var prepareOutreach = {
4326
5109
  },
4327
5110
  enrich: {
4328
5111
  type: "boolean",
4329
- description: "If true and credits available, trigger enrichment on the recommended contact (default: false). Enrichment is async \u2014 poll leadbay_get_contacts after ~60s."
5112
+ 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)."
4330
5113
  }
4331
5114
  },
4332
5115
  required: ["leadId"],
@@ -4337,32 +5120,67 @@ var prepareOutreach = {
4337
5120
  properties: {
4338
5121
  lead: {
4339
5122
  type: ["object", "null"],
4340
- description: "Short lead summary for outreach context: {name, ai_summary, website}. Null if /lead profile fetch failed.",
5123
+ description: "Lead context for the brief. Expanded per B15: score, ai_summary, split_ai_summary, location, size, phone_numbers, website, description.",
4341
5124
  properties: {
5125
+ id: { type: "string" },
4342
5126
  name: { type: "string" },
5127
+ score: { type: ["number", "null"] },
5128
+ ai_agent_lead_score: { type: ["number", "null"] },
4343
5129
  ai_summary: { type: ["string", "null"] },
4344
- website: { type: ["string", "null"] }
5130
+ split_ai_summary: { type: ["object", "null"] },
5131
+ location: { type: ["object", "null"] },
5132
+ size: { type: ["object", "null"] },
5133
+ phone_numbers: { type: ["array", "null"], items: { type: "string" } },
5134
+ website: { type: ["string", "null"] },
5135
+ description: { type: ["string", "null"] },
5136
+ short_description: { type: ["string", "null"] },
5137
+ social_presence: { type: ["object", "null"] },
5138
+ social_urls: { type: ["object", "null"] }
4345
5139
  }
4346
5140
  },
4347
5141
  recommended_contact: {
4348
5142
  type: ["object", "null"],
4349
- description: "Best contact to outreach to ({id, name, job_title, email, phone_number, linkedin_page}). Null when no contacts known."
5143
+ 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.",
5144
+ properties: {
5145
+ contact_id: { type: ["string", "null"] },
5146
+ first_name: { type: ["string", "null"] },
5147
+ last_name: { type: ["string", "null"] },
5148
+ job_title: { type: ["string", "null"] },
5149
+ email: { type: ["string", "null"] },
5150
+ phone_number: { type: ["string", "null"] },
5151
+ linkedin_page: { type: ["string", "null"] },
5152
+ is_org_contact: { type: ["boolean", "null"] }
5153
+ }
5154
+ },
5155
+ additional_contacts_count: {
5156
+ type: "number",
5157
+ description: "How many other contacts exist beyond the recommended one (renamed from other_contacts_count per B16; both shipped for one release)."
5158
+ },
5159
+ total_contacts_count: {
5160
+ type: "number",
5161
+ description: "Total contacts on this lead (recommended + others)."
4350
5162
  },
4351
5163
  other_contacts_count: {
4352
5164
  type: "number",
4353
- description: "How many other contacts exist beyond the recommended one (so the agent knows there's more to discover via leadbay_get_contacts)."
5165
+ description: "DEPRECATED: alias for additional_contacts_count. Will be removed in 0.10.0."
4354
5166
  },
4355
5167
  enrichment: {
4356
5168
  type: "object",
4357
- description: "Status of opt-in enrichment (only set when enrich:true was passed): {triggered, error, hint}.",
5169
+ description: "Self-polling status (B13): triggered = whether this call kicked off enrichment; complete = whether the recommended contact now has email OR phone.",
4358
5170
  properties: {
4359
5171
  triggered: { type: "boolean" },
5172
+ complete: { type: "boolean" },
4360
5173
  error: { type: ["string", "null"] },
4361
5174
  hint: { type: ["string", "null"] }
4362
5175
  }
4363
5176
  }
4364
5177
  },
4365
- required: ["recommended_contact", "other_contacts_count", "enrichment"]
5178
+ required: [
5179
+ "recommended_contact",
5180
+ "additional_contacts_count",
5181
+ "total_contacts_count",
5182
+ "enrichment"
5183
+ ]
4366
5184
  },
4367
5185
  execute: async (client, params, ctx) => {
4368
5186
  const contactsResult = await getContacts.execute(client, { leadId: params.leadId }, ctx);
@@ -4378,37 +5196,83 @@ var prepareOutreach = {
4378
5196
  enrichmentError = e?.message ?? String(e);
4379
5197
  }
4380
5198
  }
4381
- let leadSummary = null;
5199
+ let refreshed = contacts;
5200
+ if (enrichmentTriggered) {
5201
+ try {
5202
+ const again = await getContacts.execute(client, { leadId: params.leadId }, ctx);
5203
+ refreshed = again.contacts ?? contacts;
5204
+ } catch {
5205
+ }
5206
+ }
5207
+ const recommendedFresh = refreshed.find((c) => c.recommended) ?? recommended;
5208
+ let leadBlock = null;
4382
5209
  try {
4383
5210
  const profile = await getLeadProfile.execute(client, { leadId: params.leadId }, ctx);
4384
- leadSummary = {
4385
- name: profile.lead.name,
4386
- ai_summary: profile.lead.ai_summary,
4387
- website: profile.lead.website
5211
+ const p = profile.lead;
5212
+ leadBlock = {
5213
+ id: p.id ?? params.leadId,
5214
+ name: p.name ?? null,
5215
+ score: p.score ?? null,
5216
+ ai_agent_lead_score: p.ai_agent_lead_score ?? null,
5217
+ ai_summary: p.ai_summary ?? null,
5218
+ split_ai_summary: p.split_ai_summary ?? null,
5219
+ location: p.location ?? null,
5220
+ size: p.size ?? null,
5221
+ phone_numbers: p.phone_numbers ?? null,
5222
+ website: p.website ?? null,
5223
+ description: p.description ?? null,
5224
+ short_description: p.short_description ?? null,
5225
+ social_presence: p.social_presence ?? null,
5226
+ social_urls: p.social_urls ?? null
4388
5227
  };
4389
5228
  } catch {
5229
+ leadBlock = {
5230
+ id: params.leadId,
5231
+ name: null,
5232
+ ai_summary: null
5233
+ };
4390
5234
  }
5235
+ const recommendedContact = recommendedFresh ? {
5236
+ contact_id: recommendedFresh.id ?? null,
5237
+ first_name: recommendedFresh.first_name,
5238
+ last_name: recommendedFresh.last_name,
5239
+ job_title: recommendedFresh.job_title,
5240
+ email: recommendedFresh.email,
5241
+ phone_number: recommendedFresh.phone_number,
5242
+ linkedin_page: normalizeLinkedinPage2(recommendedFresh.linkedin_page),
5243
+ is_org_contact: recommendedFresh.source === "org"
5244
+ } : null;
5245
+ const total = refreshed.length;
5246
+ const additional = recommendedFresh ? Math.max(0, total - 1) : total;
5247
+ const enrichmentComplete = Boolean(recommendedContact && (recommendedContact.email || recommendedContact.phone_number));
4391
5248
  return {
4392
- lead: leadSummary,
4393
- recommended_contact: recommended ? {
4394
- id: recommended.id,
4395
- name: [recommended.first_name, recommended.last_name].filter(Boolean).join(" "),
4396
- job_title: recommended.job_title,
4397
- email: recommended.email,
4398
- phone_number: recommended.phone_number,
4399
- linkedin_page: recommended.linkedin_page
4400
- } : null,
4401
- other_contacts_count: Math.max(0, contacts.length - 1),
5249
+ lead: leadBlock,
5250
+ recommended_contact: recommendedContact,
5251
+ additional_contacts_count: additional,
5252
+ total_contacts_count: total,
5253
+ // Deprecated alias kept for one release.
5254
+ other_contacts_count: additional,
4402
5255
  enrichment: {
4403
5256
  triggered: enrichmentTriggered,
5257
+ complete: enrichmentComplete,
4404
5258
  error: enrichmentError,
4405
- hint: enrichmentTriggered ? "Enrichment started. Poll leadbay_get_contacts with the same leadId in ~60 seconds." : null
5259
+ hint: enrichmentTriggered && !enrichmentComplete ? "Enrichment running (~60s). Re-call leadbay_prepare_outreach with the same leadId (no enrich) and check enrichment.complete." : null
4406
5260
  }
4407
5261
  };
4408
5262
  }
4409
5263
  };
4410
5264
 
4411
5265
  // ../core/dist/composite/pull-leads.js
5266
+ function normalizeLinkedinPage3(v) {
5267
+ if (v == null)
5268
+ return null;
5269
+ if (typeof v !== "string")
5270
+ return null;
5271
+ const trimmed = v.trim();
5272
+ if (!trimmed || trimmed.toLowerCase() === "null")
5273
+ return null;
5274
+ return trimmed;
5275
+ }
4412
5276
  function summarise(responses) {
4413
5277
  const answered = responses.filter((r) => r.score != null).length;
4414
5278
  const total = responses.length;
@@ -4520,18 +5384,29 @@ var pullLeads = {
4520
5384
  }
4521
5385
  }));
4522
5386
  const summaryMap = new Map(summaries.map((s) => [s.leadId, s.summary]));
4523
- const trimmed = (lead) => verbose ? lead : {
5387
+ const augmentContact2 = (c) => c ? {
5388
+ ...c,
5389
+ linkedin_page: normalizeLinkedinPage3(c.linkedin_page ?? null)
5390
+ } : null;
5391
+ const trimmed = (lead) => verbose ? {
5392
+ ...lead,
5393
+ recommended_contact: augmentContact2(lead.recommended_contact)
5394
+ } : {
4524
5395
  id: lead.id,
4525
5396
  name: lead.name,
4526
5397
  score: lead.score,
4527
5398
  ai_agent_lead_score: lead.ai_agent_lead_score,
5399
+ ai_summary: lead.ai_summary ?? null,
5400
+ split_ai_summary: lead.split_ai_summary ?? null,
4528
5401
  location: lead.location,
4529
5402
  short_description: lead.short_description ?? lead.description,
4530
5403
  size: lead.size,
4531
5404
  website: lead.website,
5405
+ phone_numbers: lead.phone_numbers ?? null,
4532
5406
  tags: lead.tags,
4533
- recommended_contact_title: lead.recommended_contact_title ?? null,
4534
- recommended_contact: lead.recommended_contact ?? null,
5407
+ social_presence: lead.social_presence ?? null,
5408
+ social_urls: lead.social_urls ?? null,
5409
+ recommended_contact: augmentContact2(lead.recommended_contact),
4535
5410
  web_fetch_in_progress: lead.web_fetch_in_progress ?? false,
4536
5411
  enrichment_in_progress: lead.enrichment_in_progress ?? false,
4537
5412
  liked: lead.liked,
@@ -4566,7 +5441,182 @@ var pullLeads = {
4566
5441
  }
4567
5442
  };
4568
5443
 
5444
+ // ../core/dist/composite/pull-followups.js
5445
+ function normalizeLinkedinPage4(v) {
5446
+ if (v == null)
5447
+ return null;
5448
+ if (typeof v !== "string")
5449
+ return null;
5450
+ const trimmed = v.trim();
5451
+ if (!trimmed || trimmed.toLowerCase() === "null")
5452
+ return null;
5453
+ return trimmed;
5454
+ }
5455
+ function augmentContact(c) {
5456
+ if (!c)
5457
+ return null;
5458
+ return {
5459
+ ...c,
5460
+ linkedin_page: normalizeLinkedinPage4(c.linkedin_page ?? null)
5461
+ };
5462
+ }
5463
+ var pullFollowups = {
5464
+ name: "leadbay_pull_followups",
5465
+ annotations: {
5466
+ title: "Pull known leads to follow up on (Monitor view)",
5467
+ readOnlyHint: true,
5468
+ destructiveHint: false,
5469
+ idempotentHint: true,
5470
+ openWorldHint: true
5471
+ },
5472
+ description: leadbay_pull_followups,
5473
+ inputSchema: {
5474
+ type: "object",
5475
+ properties: {
5476
+ filtered: {
5477
+ type: "boolean",
5478
+ description: "Apply the user's stored Monitor filter (server-persisted via POST /monitor/filter). Default true."
5479
+ },
5480
+ personal: {
5481
+ type: "boolean",
5482
+ description: "When true, restrict to leads this user has personally monitored (not org-wide). Default false."
5483
+ },
5484
+ liked: {
5485
+ type: "boolean",
5486
+ description: "When true, restrict to leads the user has explicitly liked. Default false."
5487
+ },
5488
+ count: {
5489
+ type: "number",
5490
+ description: "Leads per page, max 200 (default 20)."
5491
+ },
5492
+ page: {
5493
+ type: "number",
5494
+ description: "Page number, 0-indexed (default 0)."
5495
+ },
5496
+ set_filter: {
5497
+ type: "object",
5498
+ 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.",
5499
+ properties: {
5500
+ criteria: {
5501
+ type: "array",
5502
+ 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).",
5503
+ items: { type: "object" }
5504
+ }
5505
+ }
5506
+ }
5507
+ },
5508
+ additionalProperties: false
5509
+ },
5510
+ outputSchema: {
5511
+ type: "object",
5512
+ properties: {
5513
+ active_filters: {
5514
+ type: ["object", "null"],
5515
+ 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."
5516
+ },
5517
+ leads: {
5518
+ type: "array",
5519
+ description: "The page of monitored leads. Each lead carries the FullLead shape augmented with normalized linkedin_page on contacts and `recommended_contact`.",
5520
+ items: { type: "object" }
5521
+ },
5522
+ pagination: {
5523
+ type: ["object", "null"],
5524
+ description: "page / pages / total \u2014 the backend's pagination envelope when present."
5525
+ },
5526
+ total_excluded_by_pushback: {
5527
+ type: "number",
5528
+ description: "Composite-derived count of leads in the page that were excluded because their `pushback_status` is active. The backend may or may not pre-filter; this exposes the count when the composite has to drop them itself."
5529
+ },
5530
+ _meta: {
5531
+ type: "object",
5532
+ description: "Operator context: region + last-call latency.",
5533
+ properties: {
5534
+ region: { type: "string" },
5535
+ latency_ms: { type: ["number", "null"] }
5536
+ }
5537
+ }
5538
+ },
5539
+ required: ["leads"]
5540
+ },
5541
+ execute: async (client, params, ctx) => {
5542
+ const filtered = params.filtered ?? true;
5543
+ const personal = params.personal ?? false;
5544
+ const liked = params.liked ?? false;
5545
+ const page = params.page ?? 0;
5546
+ const count = Math.min(params.count ?? 20, 200);
5547
+ if (params.set_filter) {
5548
+ try {
5549
+ await client.requestVoid("POST", "/monitor/filter", params.set_filter);
5550
+ } catch (err) {
5551
+ ctx?.logger?.warn?.(`pull_followups: POST /monitor/filter failed: ${err?.message ?? err?.code ?? err}`);
5552
+ }
5553
+ }
5554
+ const qs = new URLSearchParams({
5555
+ personal: String(personal),
5556
+ liked: String(liked),
5557
+ filtered: String(filtered),
5558
+ count: String(count),
5559
+ page: String(page)
5560
+ }).toString();
5561
+ const [filterR, monitorR] = await Promise.allSettled([
5562
+ filtered ? client.request("GET", "/monitor/filter") : Promise.resolve(null),
5563
+ client.request("GET", `/monitor?${qs}`)
5564
+ ]);
5565
+ const activeFilter = filterR.status === "fulfilled" ? filterR.value ?? null : null;
5566
+ if (monitorR.status === "rejected") {
5567
+ throw monitorR.reason;
5568
+ }
5569
+ const monitor = monitorR.value ?? {};
5570
+ const rawLeads = Array.isArray(monitor.items) ? monitor.items : Array.isArray(monitor.leads) ? monitor.leads : Array.isArray(monitor) ? monitor : [];
5571
+ const now = Date.now();
5572
+ const isActivePushback = (lead) => {
5573
+ const status = lead?.pushback_status;
5574
+ if (!status)
5575
+ return false;
5576
+ const until = lead?.pushback_until ?? lead?.pushback_status_set_at;
5577
+ if (!until)
5578
+ return true;
5579
+ const ts = Date.parse(until);
5580
+ if (Number.isNaN(ts))
5581
+ return true;
5582
+ return ts > now;
5583
+ };
5584
+ let excluded = 0;
5585
+ const leads = rawLeads.filter((lead) => {
5586
+ if (isActivePushback(lead)) {
5587
+ excluded += 1;
5588
+ return false;
5589
+ }
5590
+ return true;
5591
+ }).map((lead) => ({
5592
+ ...lead,
5593
+ recommended_contact: augmentContact(lead.recommended_contact),
5594
+ org_contacts: Array.isArray(lead.org_contacts) ? lead.org_contacts.map(augmentContact) : lead.org_contacts ?? null
5595
+ }));
5596
+ return {
5597
+ active_filters: activeFilter,
5598
+ leads,
5599
+ pagination: monitor.pagination ?? null,
5600
+ total_excluded_by_pushback: excluded,
5601
+ _meta: {
5602
+ region: client.region,
5603
+ latency_ms: client.lastMeta?.latency_ms ?? null
5604
+ }
5605
+ };
5606
+ }
5607
+ };
5608
+
4569
5609
  // ../core/dist/composite/research-lead.js
5610
+ function normalizeLinkedinPage5(v) {
5611
+ if (v == null)
5612
+ return null;
5613
+ if (typeof v !== "string")
5614
+ return null;
5615
+ const trimmed = v.trim();
5616
+ if (!trimmed || trimmed.toLowerCase() === "null")
5617
+ return null;
5618
+ return trimmed;
5619
+ }
4570
5620
  function renderResearchLeadMarkdown(shape) {
4571
5621
  const out = [];
4572
5622
  const firm = shape.firmographics ?? {};
@@ -4743,22 +5793,96 @@ var researchLead = {
4743
5793
  },
4744
5794
  firmographics: {
4745
5795
  type: "object",
4746
- description: "Lead profile basics. iter-30: nested additionalProperties:false closes the output-side strictness frontier \u2014 runtime returns must match exactly these keys.",
5796
+ 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.",
4747
5797
  properties: {
4748
5798
  id: { type: "string" },
4749
5799
  name: { type: "string" },
4750
5800
  sector_id: { type: ["number", "string", "null"] },
4751
- size: { type: ["string", "null"] },
4752
- location: { type: ["string", "null"] },
5801
+ size: {
5802
+ type: ["object", "null"],
5803
+ description: "LeadSimplified.size \u2014 employee-count band.",
5804
+ properties: {
5805
+ low: { type: ["number", "null"] },
5806
+ high: { type: ["number", "null"] },
5807
+ min: { type: ["number", "null"] },
5808
+ max: { type: ["number", "null"] },
5809
+ label: { type: ["string", "null"] }
5810
+ }
5811
+ },
5812
+ location: {
5813
+ type: ["object", "null"],
5814
+ description: "LeadFullLocation \u2014 city/state/country/full/pos.",
5815
+ properties: {
5816
+ city: { type: ["string", "null"] },
5817
+ state: { type: ["string", "null"] },
5818
+ country: { type: ["string", "null"] },
5819
+ full: { type: ["string", "null"] },
5820
+ pos: {
5821
+ type: ["array", "null"],
5822
+ items: { type: "number" }
5823
+ }
5824
+ }
5825
+ },
4753
5826
  website: { type: ["string", "null"] },
4754
5827
  description: { type: ["string", "null"] },
4755
5828
  short_description: { type: ["string", "null"] },
4756
- keywords: { type: "array", items: { type: "string" } },
4757
- tags: { type: "array", items: { type: "string" } },
5829
+ keywords: {
5830
+ type: "array",
5831
+ description: "Either bare strings (legacy) or {keyword,score} objects depending on the backend payload version.",
5832
+ items: {}
5833
+ },
5834
+ tags: {
5835
+ type: "array",
5836
+ description: "LeadTag[] \u2014 {id, display_name, tag, score}.",
5837
+ items: {
5838
+ type: "object",
5839
+ properties: {
5840
+ id: { type: ["number", "string", "null"] },
5841
+ display_name: { type: ["string", "null"] },
5842
+ tag: { type: "string" },
5843
+ score: { type: ["number", "null"] }
5844
+ }
5845
+ }
5846
+ },
4758
5847
  score: { type: ["number", "null"] },
4759
5848
  ai_agent_lead_score: { type: ["number", "null"] },
4760
- social_presence: { type: ["object", "string", "null"] },
4761
- social_urls: { type: ["object", "array", "null"] },
5849
+ ai_summary: { type: ["string", "null"] },
5850
+ split_ai_summary: {
5851
+ type: ["object", "null"],
5852
+ properties: {
5853
+ worth_pursuing: { type: ["string", "null"] },
5854
+ approach_angle: { type: ["string", "null"] },
5855
+ next_step: { type: ["string", "null"] }
5856
+ }
5857
+ },
5858
+ phone_numbers: {
5859
+ type: ["array", "null"],
5860
+ items: { type: "string" }
5861
+ },
5862
+ social_presence: {
5863
+ type: ["object", "null"],
5864
+ description: "LeadSocialPresence \u2014 6 booleans per platform. Use `social_urls` for URLs.",
5865
+ properties: {
5866
+ crunchbase: { type: "boolean" },
5867
+ facebook: { type: "boolean" },
5868
+ instagram: { type: "boolean" },
5869
+ linkedin: { type: "boolean" },
5870
+ tiktok: { type: "boolean" },
5871
+ twitter: { type: "boolean" }
5872
+ }
5873
+ },
5874
+ social_urls: {
5875
+ type: ["object", "null"],
5876
+ description: "LeadSocialUrls \u2014 URL strings per platform; null when the company has no profile.",
5877
+ properties: {
5878
+ crunchbase: { type: ["string", "null"] },
5879
+ facebook: { type: ["string", "null"] },
5880
+ instagram: { type: ["string", "null"] },
5881
+ linkedin: { type: ["string", "null"] },
5882
+ tiktok: { type: ["string", "null"] },
5883
+ twitter: { type: ["string", "null"] }
5884
+ }
5885
+ },
4762
5886
  registry_ids: { type: ["object", "array", "null"] }
4763
5887
  },
4764
5888
  additionalProperties: false
@@ -4779,7 +5903,6 @@ var researchLead = {
4779
5903
  liked: { type: "boolean" },
4780
5904
  disliked: { type: "boolean" },
4781
5905
  new: { type: "boolean" },
4782
- recommended_contact_title: { type: ["string", "null"] },
4783
5906
  recommended_contact: { type: ["object", "null"] },
4784
5907
  notes_count: { type: "number" },
4785
5908
  epilogue_actions_count: { type: "number" },
@@ -4900,11 +6023,16 @@ var researchLead = {
4900
6023
  tags: lead.tags,
4901
6024
  score: lead.score,
4902
6025
  ai_agent_lead_score: lead.ai_agent_lead_score,
6026
+ ai_summary: lead.ai_summary ?? null,
6027
+ split_ai_summary: lead.split_ai_summary ?? null,
6028
+ phone_numbers: lead.phone_numbers ?? null,
4903
6029
  social_presence: lead.social_presence ?? null,
4904
6030
  social_urls: lead.social_urls ?? null,
4905
6031
  registry_ids: lead.registry_ids ?? null
4906
6032
  },
4907
6033
  // 4) contacts (paid/enriched, plus org contacts if present)
6034
+ // B6: defensively coerce the literal string "null" to a real null —
6035
+ // some backend serializers emit it for un-enriched contacts.
4908
6036
  contacts: {
4909
6037
  enriched: paidContacts.map((c) => ({
4910
6038
  id: c.id,
@@ -4913,7 +6041,7 @@ var researchLead = {
4913
6041
  job_title: c.job_title,
4914
6042
  email: c.email,
4915
6043
  phone_number: c.phone_number,
4916
- linkedin_page: c.linkedin_page,
6044
+ linkedin_page: normalizeLinkedinPage5(c.linkedin_page),
4917
6045
  recommended: c.recommended,
4918
6046
  enrichment_done: c.enrichment?.done ?? false
4919
6047
  })),
@@ -4922,16 +6050,22 @@ var researchLead = {
4922
6050
  first_name: c.first_name,
4923
6051
  last_name: c.last_name,
4924
6052
  job_title: c.job_title,
4925
- email: c.email
6053
+ email: c.email,
6054
+ linkedin_page: normalizeLinkedinPage5(c.linkedin_page ?? null)
4926
6055
  }))
4927
6056
  },
4928
- // 5) engagement — what humans/prior agent runs already did
6057
+ // 5) engagement — what humans/prior agent runs already did.
6058
+ // B8: `recommended_contact_title` dropped — it duplicates
6059
+ // `recommended_contact.job_title` and just confused agents.
4929
6060
  engagement: {
4930
6061
  liked: lead.liked,
4931
6062
  disliked: lead.disliked,
4932
6063
  new: lead.new ?? false,
4933
- recommended_contact_title: lead.recommended_contact_title ?? null,
4934
- recommended_contact: lead.recommended_contact ?? null,
6064
+ recommended_contact: lead.recommended_contact ? {
6065
+ ...lead.recommended_contact,
6066
+ // B1+B7: propagate linkedin_page when the backend includes it.
6067
+ linkedin_page: normalizeLinkedinPage5(lead.recommended_contact.linkedin_page ?? null)
6068
+ } : null,
4935
6069
  notes_count: lead.notes_count ?? 0,
4936
6070
  epilogue_actions_count: lead.epilogue_actions_count ?? 0,
4937
6071
  prospecting_actions_count: lead.prospecting_actions_count ?? 0,
@@ -8927,6 +10061,8 @@ var granularWriteTools = [
8927
10061
  dismissClarification,
8928
10062
  setEpilogueStatus,
8929
10063
  removeEpilogue,
10064
+ setPushback,
10065
+ removePushback,
8930
10066
  previewBulkEnrichment,
8931
10067
  launchBulkEnrichment,
8932
10068
  createCustomField
@@ -8941,6 +10077,7 @@ granularTools.forEach((t) => {
8941
10077
  });
8942
10078
  var compositeReadTools = [
8943
10079
  pullLeads,
10080
+ pullFollowups,
8944
10081
  researchLead,
8945
10082
  recallOrderedTitles,
8946
10083
  accountStatus,
@@ -9025,12 +10162,15 @@ export {
9025
10162
  dismissClarification,
9026
10163
  setEpilogueStatus,
9027
10164
  removeEpilogue,
10165
+ setPushback,
10166
+ removePushback,
9028
10167
  previewBulkEnrichment,
9029
10168
  launchBulkEnrichment,
9030
10169
  createCustomField,
9031
10170
  researchCompany,
9032
10171
  prepareOutreach,
9033
10172
  pullLeads,
10173
+ pullFollowups,
9034
10174
  researchLead,
9035
10175
  recallOrderedTitles,
9036
10176
  accountStatus,