@leadbay/mcp 0.5.0 → 0.6.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.
@@ -535,6 +535,13 @@ function parseRetryAfter(value) {
535
535
  // ../core/dist/tools/login.js
536
536
  var login = {
537
537
  name: "leadbay_login",
538
+ annotations: {
539
+ title: "Mint a Leadbay bearer token",
540
+ readOnlyHint: false,
541
+ destructiveHint: true,
542
+ idempotentHint: false,
543
+ openWorldHint: true
544
+ },
538
545
  description: "Log in to Leadbay with email and password. Auto-detects region (us|fr) \u2014 the user does not need to know which backend their account lives on. When to use: at the start of a session if no token is preconfigured (cfg.token / LEADBAY_TOKEN). When NOT to use: if a token is already preconfigured (you'll just overwrite it). The user needs a Leadbay account \u2014 they can register at https://wow.leadbay.ai/?register=true",
539
546
  inputSchema: {
540
547
  type: "object",
@@ -542,7 +549,8 @@ var login = {
542
549
  email: { type: "string", description: "Leadbay account email address" },
543
550
  password: { type: "string", description: "Leadbay account password" }
544
551
  },
545
- required: ["email", "password"]
552
+ required: ["email", "password"],
553
+ additionalProperties: false
546
554
  },
547
555
  execute: async (client, params, ctx) => {
548
556
  const cleanPassword = params.password.replace(/\\(.)/g, "$1");
@@ -578,10 +586,37 @@ var login = {
578
586
  // ../core/dist/tools/list-lenses.js
579
587
  var listLenses = {
580
588
  name: "leadbay_list_lenses",
589
+ annotations: {
590
+ title: "List active lenses",
591
+ readOnlyHint: true,
592
+ destructiveHint: false,
593
+ idempotentHint: true,
594
+ openWorldHint: true
595
+ },
581
596
  description: "List all available Leadbay lenses (saved lead search configurations). Each lens defines a different target market or buyer segment. The lens with is_last_active=true is used by default for lead discovery. When to use: when the user wants to switch lens or asks 'what lenses do I have'. When NOT to use: in normal flow \u2014 composites auto-resolve the active lens via /me.last_requested_lens.",
582
597
  inputSchema: {
583
598
  type: "object",
584
- properties: {}
599
+ properties: {},
600
+ additionalProperties: false
601
+ },
602
+ outputSchema: {
603
+ type: "object",
604
+ properties: {
605
+ lenses: {
606
+ type: "array",
607
+ description: "Available lenses. Each: {id, name, is_last_active, description}.",
608
+ items: {
609
+ type: "object",
610
+ properties: {
611
+ id: { type: "number" },
612
+ name: { type: "string" },
613
+ is_last_active: { type: "boolean" },
614
+ description: { type: ["string", "null"] }
615
+ }
616
+ }
617
+ }
618
+ },
619
+ required: ["lenses"]
585
620
  },
586
621
  execute: async (client) => {
587
622
  const lenses = await client.request("GET", "/lenses");
@@ -599,6 +634,13 @@ var listLenses = {
599
634
  // ../core/dist/tools/discover-leads.js
600
635
  var discoverLeads = {
601
636
  name: "leadbay_discover_leads",
637
+ annotations: {
638
+ title: "Discover leads in a lens",
639
+ readOnlyHint: true,
640
+ destructiveHint: false,
641
+ idempotentHint: true,
642
+ openWorldHint: true
643
+ },
602
644
  description: "Get AI-recommended leads from Leadbay. Returns paginated lead summaries with scores, AI summaries, tags, and recommended contacts. When to use: low-level when you need raw paginated wishlist access without the qualification_summary attached by leadbay_pull_leads. When NOT to use: as the agent's default lead-discovery entry point \u2014 use leadbay_pull_leads, which adds a one-line qualification summary per lead.",
603
645
  inputSchema: {
604
646
  type: "object",
@@ -615,13 +657,18 @@ var discoverLeads = {
615
657
  type: "number",
616
658
  description: "Results per page, max 50 (default: 20)"
617
659
  }
618
- }
660
+ },
661
+ additionalProperties: false
619
662
  },
620
663
  execute: async (client, params) => {
621
664
  const lensId = params.lensId ?? await client.resolveDefaultLens();
622
665
  const page = params.page ?? 0;
623
666
  const count = Math.min(params.count ?? 20, 50);
624
667
  const res = await client.request("GET", `/lenses/${lensId}/leads/wishlist?count=${count}&page=${page}&contacts=true`);
668
+ const totalPages = res.pagination?.pages ?? 0;
669
+ const currentPage = res.pagination?.page ?? page;
670
+ const hasMore = currentPage < totalPages - 1;
671
+ const nextPage = hasMore ? currentPage + 1 : null;
625
672
  return {
626
673
  leads: res.items.map((lead) => ({
627
674
  id: lead.id,
@@ -641,7 +688,9 @@ var discoverLeads = {
641
688
  recommended_contact_title: lead.recommended_contact_title ?? null,
642
689
  recommended_contact: lead.recommended_contact ?? null
643
690
  })),
644
- pagination: res.pagination
691
+ pagination: res.pagination,
692
+ has_more: hasMore,
693
+ next_page: nextPage
645
694
  };
646
695
  }
647
696
  };
@@ -649,6 +698,13 @@ var discoverLeads = {
649
698
  // ../core/dist/tools/get-lead-profile.js
650
699
  var getLeadProfile = {
651
700
  name: "leadbay_get_lead_profile",
701
+ annotations: {
702
+ title: "Read a lead profile",
703
+ readOnlyHint: true,
704
+ destructiveHint: false,
705
+ idempotentHint: true,
706
+ openWorldHint: true
707
+ },
652
708
  description: "Get a full lead profile including company details, AI qualification scores, web insights, and contacts. When to use: low-level \u2014 for fine-grained access to the raw shape of the lead profile. When NOT to use: as the agent's default lead-detail tool \u2014 use leadbay_research_lead, which structures the data top-down (qualification first, then signals, then firmographics, then contacts, then engagement) and reshapes web_fetch.content into a stable array form.",
653
709
  inputSchema: {
654
710
  type: "object",
@@ -662,7 +718,30 @@ var getLeadProfile = {
662
718
  description: "Lens ID (optional, auto-resolves to active lens)"
663
719
  }
664
720
  },
665
- required: ["leadId"]
721
+ required: ["leadId"],
722
+ additionalProperties: false
723
+ },
724
+ outputSchema: {
725
+ type: "object",
726
+ properties: {
727
+ lead: {
728
+ type: "object",
729
+ description: "Lead basics: id, name, score, ai_agent_lead_score, location, description, short_description, size, website, logo, ai_summary, split_ai_summary, tags, phone_numbers, keywords, contacts_count, recommended_contact_title, recommended_contact, web_fetch_in_progress."
730
+ },
731
+ qualification: {
732
+ type: ["array", "null"],
733
+ description: "Per-question AI qualification answers ({question, score, response, computed_at, outdated_at}), or null if none.",
734
+ items: { type: "object" }
735
+ },
736
+ contacts: {
737
+ type: "array",
738
+ description: "Merged org+paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
739
+ items: { type: "object" }
740
+ },
741
+ web_insights: { description: "Latest /web_fetch content (string) or null." },
742
+ web_insights_fetched_at: { description: "ISO timestamp of /web_fetch (string) or null." }
743
+ },
744
+ required: ["lead", "contacts"]
666
745
  },
667
746
  execute: async (client, params) => {
668
747
  const lensId = params.lensId ?? await client.resolveDefaultLens();
@@ -751,6 +830,13 @@ var getLeadProfile = {
751
830
  // ../core/dist/tools/get-contacts.js
752
831
  var getContacts = {
753
832
  name: "leadbay_get_contacts",
833
+ annotations: {
834
+ title: "Read enriched contacts",
835
+ readOnlyHint: true,
836
+ destructiveHint: false,
837
+ idempotentHint: true,
838
+ openWorldHint: true
839
+ },
754
840
  description: "Get contacts for a lead, including enriched email and phone data. Returns both organization contacts and enrichable contacts with IDs. When to use: to check enrichment status (contact.enrichment.done) on individual leads after a bulk enrichment was launched, or to find the contact_id needed by leadbay_enrich_contacts. When NOT to use: as a substitute for leadbay_research_lead, which already includes enriched contacts in its return.",
755
841
  inputSchema: {
756
842
  type: "object",
@@ -760,7 +846,8 @@ var getContacts = {
760
846
  description: "Lead UUID (required)"
761
847
  }
762
848
  },
763
- required: ["leadId"]
849
+ required: ["leadId"],
850
+ additionalProperties: false
764
851
  },
765
852
  execute: async (client, params) => {
766
853
  const [orgResult, paidResult] = await Promise.allSettled([
@@ -803,8 +890,26 @@ var getContacts = {
803
890
  // ../core/dist/tools/get-quota.js
804
891
  var getQuota = {
805
892
  name: "leadbay_get_quota",
893
+ annotations: {
894
+ title: "Read quota status",
895
+ readOnlyHint: true,
896
+ destructiveHint: false,
897
+ idempotentHint: true,
898
+ openWorldHint: true
899
+ },
806
900
  description: "Read remaining quota / spend across daily, weekly, monthly windows for the org's resources (llm_completion, ai_rescore, web_fetch). Each entry shows current_units vs max_units and resets_at. When to use: after a 429 error, to explain to the user which window was hit and when it resets. When NOT to use: as a pre-flight gate before bulk operations \u2014 operations themselves return 429 with hints; this tool is for diagnostics, not gating.",
807
- inputSchema: { type: "object", properties: {} },
901
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
902
+ outputSchema: {
903
+ type: "object",
904
+ properties: {
905
+ plan: { type: ["string", "null"], description: "Org plan tier (e.g., FREE, PRO)." },
906
+ windows: {
907
+ type: "array",
908
+ description: "Per-resource per-window limits. Each: {resource, window, current_units, max_units, resets_at}.",
909
+ items: { type: "object" }
910
+ }
911
+ }
912
+ },
808
913
  execute: async (client) => {
809
914
  const orgId = await client.resolveOrgId();
810
915
  return await client.request("GET", `/organizations/${orgId}/quota_status`);
@@ -814,10 +919,41 @@ var getQuota = {
814
919
  // ../core/dist/tools/get-taste-profile.js
815
920
  var getTasteProfile = {
816
921
  name: "leadbay_get_taste_profile",
922
+ annotations: {
923
+ title: "Read the org's taste profile",
924
+ readOnlyHint: true,
925
+ destructiveHint: false,
926
+ idempotentHint: true,
927
+ openWorldHint: true
928
+ },
817
929
  description: "Get the user's Ideal Buyer Profile, purchase intent tags, and qualification questions. When to use: at the very start of a session to understand what kind of leads the user is looking for. Data is cached. When NOT to use: per-lead \u2014 leadbay_research_lead already includes the per-lead qualification answers (which are scored against these org-level questions).",
818
930
  inputSchema: {
819
931
  type: "object",
820
- properties: {}
932
+ properties: {},
933
+ additionalProperties: false
934
+ },
935
+ outputSchema: {
936
+ type: "object",
937
+ properties: {
938
+ ideal_buyer_profile: {
939
+ description: "Ideal Buyer Profile {summary, key_characteristics, anti_patterns} or null when none."
940
+ },
941
+ purchase_intent_tags: {
942
+ type: "array",
943
+ description: "Tags describing buying signals. Each: {display_name, description, score, reasoning}.",
944
+ items: { type: "object" }
945
+ },
946
+ qualification_questions: {
947
+ type: "array",
948
+ description: "Questions Leadbay asks for each lead. Each: {question}.",
949
+ items: { type: "object" }
950
+ },
951
+ hint: {
952
+ type: "string",
953
+ description: "Operator note when the taste profile is empty (no_profile state)."
954
+ }
955
+ },
956
+ required: ["purchase_intent_tags", "qualification_questions"]
821
957
  },
822
958
  execute: async (client) => {
823
959
  const profile = await client.resolveTasteProfile();
@@ -847,6 +983,13 @@ var getTasteProfile = {
847
983
  // ../core/dist/tools/qualify-lead.js
848
984
  var qualifyLead = {
849
985
  name: "leadbay_qualify_lead",
986
+ annotations: {
987
+ title: "Qualify a single lead",
988
+ readOnlyHint: false,
989
+ destructiveHint: true,
990
+ idempotentHint: true,
991
+ openWorldHint: true
992
+ },
850
993
  description: "Trigger AI qualification for a single lead (web fetch + AI rescore). The operation is asynchronous \u2014 results take ~60s. When to use: low-level. When NOT to use: as the agent's bulk-qualify path \u2014 use leadbay_bulk_qualify_leads, which paginates past already-qualified leads, fan-outs, polls, and bails out cleanly on 429.",
851
994
  optional: true,
852
995
  inputSchema: {
@@ -861,7 +1004,8 @@ var qualifyLead = {
861
1004
  description: "Force re-fetch even if recent data exists (default: false)"
862
1005
  }
863
1006
  },
864
- required: ["leadId"]
1007
+ required: ["leadId"],
1008
+ additionalProperties: false
865
1009
  },
866
1010
  execute: async (client, params) => {
867
1011
  const force = params.forceFetch ?? false;
@@ -876,6 +1020,13 @@ var qualifyLead = {
876
1020
  // ../core/dist/tools/enrich-contacts.js
877
1021
  var enrichContacts = {
878
1022
  name: "leadbay_enrich_contacts",
1023
+ annotations: {
1024
+ title: "Enrich contacts for a lead",
1025
+ readOnlyHint: false,
1026
+ destructiveHint: true,
1027
+ idempotentHint: true,
1028
+ openWorldHint: true
1029
+ },
879
1030
  description: "Order email and/or phone enrichment for a specific contact. When to use: when you have a specific contact_id (from leadbay_get_contacts) and want to enrich just that one. When NOT to use: for bulk enrichment by job title across many leads \u2014 use leadbay_enrich_titles, which handles the selection lifecycle and returns a clean preview/launch flow.",
880
1031
  optional: true,
881
1032
  inputSchema: {
@@ -898,7 +1049,8 @@ var enrichContacts = {
898
1049
  description: "Enrich phone number (default: true)"
899
1050
  }
900
1051
  },
901
- required: ["leadId", "contactId"]
1052
+ required: ["leadId", "contactId"],
1053
+ additionalProperties: false
902
1054
  },
903
1055
  execute: async (client, params) => {
904
1056
  const email = params.email ?? true;
@@ -942,6 +1094,13 @@ var enrichContacts = {
942
1094
  // ../core/dist/tools/add-note.js
943
1095
  var addNote = {
944
1096
  name: "leadbay_add_note",
1097
+ annotations: {
1098
+ title: "Add a note on a lead",
1099
+ readOnlyHint: false,
1100
+ destructiveHint: true,
1101
+ idempotentHint: false,
1102
+ openWorldHint: true
1103
+ },
945
1104
  description: "Add a note to a lead. Notes are visible to the whole organization in Leadbay. When to use: low-level \u2014 for free-form notes not tied to outreach actions. When NOT to use: to log an outreach action \u2014 use leadbay_report_outreach, which requires verification (gmail/calendar/user_confirmed) to prevent hallucinated outreach poisoning the SDR pipeline.",
946
1105
  optional: true,
947
1106
  inputSchema: {
@@ -956,7 +1115,17 @@ var addNote = {
956
1115
  description: "Note text (max 4095 characters)"
957
1116
  }
958
1117
  },
959
- required: ["leadId", "note"]
1118
+ required: ["leadId", "note"],
1119
+ additionalProperties: false
1120
+ },
1121
+ outputSchema: {
1122
+ type: "object",
1123
+ properties: {
1124
+ id: { type: "string", description: "Note id assigned by the backend." },
1125
+ note: { type: "string", description: "Echoed note text (truncated to 4095 chars)." },
1126
+ created_at: { type: "string", description: "ISO timestamp of creation." }
1127
+ },
1128
+ required: ["id", "note", "created_at"]
960
1129
  },
961
1130
  execute: async (client, params) => {
962
1131
  if (!params.note || params.note.trim().length === 0) {
@@ -975,6 +1144,13 @@ var addNote = {
975
1144
  // ../core/dist/tools/get-lead-activities.js
976
1145
  var getLeadActivities = {
977
1146
  name: "leadbay_get_lead_activities",
1147
+ annotations: {
1148
+ title: "Read a lead's activity feed",
1149
+ readOnlyHint: true,
1150
+ destructiveHint: false,
1151
+ idempotentHint: true,
1152
+ openWorldHint: true
1153
+ },
978
1154
  description: "Get prospecting activity history for a lead (emails sent, calls made, status changes, notes). When to use: to avoid redundant outreach and understand where this lead is in the sales process. When NOT to use: when leadbay_research_lead has already been called \u2014 it includes recent prospecting actions in its engagement block.",
979
1155
  inputSchema: {
980
1156
  type: "object",
@@ -988,7 +1164,29 @@ var getLeadActivities = {
988
1164
  description: "Number of activities to return, max 100 (default: 50)"
989
1165
  }
990
1166
  },
991
- required: ["leadId"]
1167
+ required: ["leadId"],
1168
+ additionalProperties: false
1169
+ },
1170
+ outputSchema: {
1171
+ type: "object",
1172
+ properties: {
1173
+ activities: {
1174
+ type: "array",
1175
+ description: "Activity entries. Each: {type, date}. Older activities trimmed by `count`.",
1176
+ items: {
1177
+ type: "object",
1178
+ properties: {
1179
+ type: { type: "string" },
1180
+ date: { type: "string" }
1181
+ }
1182
+ }
1183
+ },
1184
+ total: {
1185
+ type: "number",
1186
+ description: "Total activity count for this lead (across all pages)."
1187
+ }
1188
+ },
1189
+ required: ["activities", "total"]
992
1190
  },
993
1191
  execute: async (client, params) => {
994
1192
  const count = Math.min(params.count ?? 50, 100);
@@ -1006,13 +1204,21 @@ var getLeadActivities = {
1006
1204
  // ../core/dist/tools/get-lens-filter.js
1007
1205
  var getLensFilter = {
1008
1206
  name: "leadbay_get_lens_filter",
1207
+ annotations: {
1208
+ title: "Read lens filter",
1209
+ readOnlyHint: true,
1210
+ destructiveHint: false,
1211
+ idempotentHint: true,
1212
+ openWorldHint: true
1213
+ },
1009
1214
  description: "Read the firmographic filter (sectors, sizes, locations) currently applied to a lens. When to use: before adjusting an audience \u2014 see what's already restricted so changes are diffs, not full replacements. When NOT to use: to actually apply changes \u2014 use the leadbay_adjust_audience composite, which handles permissions transparently.",
1010
1215
  inputSchema: {
1011
1216
  type: "object",
1012
1217
  properties: {
1013
1218
  lensId: { type: "number", description: "Lens id (required)" }
1014
1219
  },
1015
- required: ["lensId"]
1220
+ required: ["lensId"],
1221
+ additionalProperties: false
1016
1222
  },
1017
1223
  execute: async (client, params) => {
1018
1224
  return await client.request("GET", `/lenses/${params.lensId}/filter`);
@@ -1022,11 +1228,19 @@ var getLensFilter = {
1022
1228
  // ../core/dist/tools/get-lens-scoring.js
1023
1229
  var getLensScoring = {
1024
1230
  name: "leadbay_get_lens_scoring",
1231
+ annotations: {
1232
+ title: "Read lens scoring",
1233
+ readOnlyHint: true,
1234
+ destructiveHint: false,
1235
+ idempotentHint: true,
1236
+ openWorldHint: true
1237
+ },
1025
1238
  description: "Read the AI-scoring criteria configured on a lens (what makes a lead score 100 vs 30). When to use: when explaining why a lead got the score it did. When NOT to use: to mutate scoring \u2014 that's an admin/setup operation, not part of the agent loop.",
1026
1239
  inputSchema: {
1027
1240
  type: "object",
1028
1241
  properties: { lensId: { type: "number", description: "Lens id (required)" } },
1029
- required: ["lensId"]
1242
+ required: ["lensId"],
1243
+ additionalProperties: false
1030
1244
  },
1031
1245
  execute: async (client, params) => {
1032
1246
  return await client.request("GET", `/lenses/${params.lensId}/scoring`);
@@ -1036,6 +1250,13 @@ var getLensScoring = {
1036
1250
  // ../core/dist/tools/list-sectors.js
1037
1251
  var listSectors = {
1038
1252
  name: "leadbay_list_sectors",
1253
+ annotations: {
1254
+ title: "List sector taxonomy",
1255
+ readOnlyHint: true,
1256
+ destructiveHint: false,
1257
+ idempotentHint: true,
1258
+ openWorldHint: true
1259
+ },
1039
1260
  description: "List the sector taxonomy (id + display name in the requested language). When to use: to resolve a free-text sector name (e.g. 'Healthcare') into the sector ids that leadbay_adjust_audience needs. Default: lang follows the caller's language; includeInvisible=false returns ~1,091 visible sectors. When NOT to use: when you already have sector ids \u2014 pass them directly.",
1040
1261
  inputSchema: {
1041
1262
  type: "object",
@@ -1045,7 +1266,8 @@ var listSectors = {
1045
1266
  type: "boolean",
1046
1267
  description: "Include sectors hidden from the UI (default false; ~91k items if true)"
1047
1268
  }
1048
- }
1269
+ },
1270
+ additionalProperties: false
1049
1271
  },
1050
1272
  execute: async (client, params) => {
1051
1273
  let lang = params.lang;
@@ -1066,8 +1288,36 @@ var listSectors = {
1066
1288
  // ../core/dist/tools/get-user-prompt.js
1067
1289
  var getUserPrompt = {
1068
1290
  name: "leadbay_get_user_prompt",
1291
+ annotations: {
1292
+ title: "Read user prompt",
1293
+ readOnlyHint: true,
1294
+ destructiveHint: false,
1295
+ idempotentHint: true,
1296
+ openWorldHint: true
1297
+ },
1069
1298
  description: "Read the org's intelligence-refinement prompt (free-text instruction that steers lead recommendations beyond firmographics). Returns null if none is set (the backend returns 204 in that case). When to use: to know what's currently steering the agent's recommendations before suggesting a refine. When NOT to use: to set/change the prompt \u2014 use leadbay_refine_prompt.",
1070
- inputSchema: { type: "object", properties: {} },
1299
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1300
+ outputSchema: {
1301
+ type: "object",
1302
+ properties: {
1303
+ prompt: {
1304
+ description: "Free-text instruction (string) or null when unset."
1305
+ },
1306
+ set: {
1307
+ type: "boolean",
1308
+ description: "True when a prompt is set; false when nothing has been configured."
1309
+ },
1310
+ // When the backend returns a populated UserPromptPayload, additional
1311
+ // fields may be spread into the response. The asserter is permissive
1312
+ // — declare common fields here so the conformance check accepts the
1313
+ // backend's full shape.
1314
+ user_prompt: {
1315
+ description: "Backend-form copy of the prompt text (when set)."
1316
+ },
1317
+ created_at: { type: ["string", "null"] },
1318
+ updated_at: { type: ["string", "null"] }
1319
+ }
1320
+ },
1071
1321
  execute: async (client) => {
1072
1322
  const orgId = await client.resolveOrgId();
1073
1323
  const prompt = await client.request("GET", `/organizations/${orgId}/user_prompt`);
@@ -1078,8 +1328,38 @@ var getUserPrompt = {
1078
1328
  // ../core/dist/tools/get-clarification.js
1079
1329
  var getClarification = {
1080
1330
  name: "leadbay_get_clarification",
1331
+ annotations: {
1332
+ title: "Read pending clarification",
1333
+ readOnlyHint: true,
1334
+ destructiveHint: false,
1335
+ idempotentHint: true,
1336
+ openWorldHint: true
1337
+ },
1081
1338
  description: "Check whether Leadbay has a pending clarification question \u2014 a question raised when refining the intelligence prompt produced contradictory or ambiguous criteria. Returns null when nothing is pending (the backend returns 204). When to use: after leadbay_refine_prompt, to see if Leadbay needs the user to disambiguate. When NOT to use: to answer the question \u2014 use leadbay_answer_clarification.",
1082
- inputSchema: { type: "object", properties: {} },
1339
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1340
+ outputSchema: {
1341
+ type: "object",
1342
+ properties: {
1343
+ pending: {
1344
+ type: "boolean",
1345
+ description: "False when no clarification is pending (and `clarification` is null)."
1346
+ },
1347
+ clarification: {
1348
+ description: "ClarificationPayload (object) when pending, otherwise null."
1349
+ },
1350
+ // When the backend returns a populated ClarificationPayload, the keys
1351
+ // are spread directly into the response. The asserter is permissive —
1352
+ // declare the union of keys here so the conformance check doesn't
1353
+ // flag drift.
1354
+ id: { type: "string", description: "Clarification id (when pending)." },
1355
+ question: { type: "string", description: "Question text (when pending)." },
1356
+ options: {
1357
+ type: "array",
1358
+ description: "Picker options (when pending). Each: {id, label}.",
1359
+ items: { type: "object" }
1360
+ }
1361
+ }
1362
+ },
1083
1363
  execute: async (client) => {
1084
1364
  const orgId = await client.resolveOrgId();
1085
1365
  const c = await client.request("GET", `/organizations/${orgId}/clarifications`);
@@ -1090,11 +1370,19 @@ var getClarification = {
1090
1370
  // ../core/dist/tools/get-lead-notes.js
1091
1371
  var getLeadNotes = {
1092
1372
  name: "leadbay_get_lead_notes",
1373
+ annotations: {
1374
+ title: "Read lead notes",
1375
+ readOnlyHint: true,
1376
+ destructiveHint: false,
1377
+ idempotentHint: true,
1378
+ openWorldHint: true
1379
+ },
1093
1380
  description: "Read existing notes on a lead \u2014 context the human team or prior agent runs have already captured. When to use: before adding a note via leadbay_report_outreach, to avoid duplicating or overwriting context the SDR already wrote. When NOT to use: when the lead summary's notes_count is 0 \u2014 there's nothing to fetch.",
1094
1381
  inputSchema: {
1095
1382
  type: "object",
1096
1383
  properties: { leadId: { type: "string", description: "Lead UUID (required)" } },
1097
- required: ["leadId"]
1384
+ required: ["leadId"],
1385
+ additionalProperties: false
1098
1386
  },
1099
1387
  execute: async (client, params) => {
1100
1388
  return await client.request("GET", `/leads/${params.leadId}/notes`);
@@ -1104,6 +1392,13 @@ var getLeadNotes = {
1104
1392
  // ../core/dist/tools/get-epilogue-responses.js
1105
1393
  var getEpilogueResponses = {
1106
1394
  name: "leadbay_get_epilogue_responses",
1395
+ annotations: {
1396
+ title: "Read epilogue responses",
1397
+ readOnlyHint: true,
1398
+ destructiveHint: false,
1399
+ idempotentHint: true,
1400
+ openWorldHint: true
1401
+ },
1107
1402
  description: "Read the lead's epilogue history \u2014 what status (still chasing, meeting booked, etc.) was set when, and by whom. When to use: to see the lead's outreach progression before deciding the next step. When NOT to use: when the lead summary's epilogue_actions_count is 0.",
1108
1403
  inputSchema: {
1109
1404
  type: "object",
@@ -1112,7 +1407,8 @@ var getEpilogueResponses = {
1112
1407
  count: { type: "number", description: "Items per page (1-200, default 20)" },
1113
1408
  page: { type: "number", description: "Page number, 0-indexed (default 0)" }
1114
1409
  },
1115
- required: ["leadId"]
1410
+ required: ["leadId"],
1411
+ additionalProperties: false
1116
1412
  },
1117
1413
  execute: async (client, params) => {
1118
1414
  const count = params.count ?? 20;
@@ -1124,6 +1420,13 @@ var getEpilogueResponses = {
1124
1420
  // ../core/dist/tools/get-prospecting-actions.js
1125
1421
  var getProspectingActions = {
1126
1422
  name: "leadbay_get_prospecting_actions",
1423
+ annotations: {
1424
+ title: "Read prospecting actions",
1425
+ readOnlyHint: true,
1426
+ destructiveHint: false,
1427
+ idempotentHint: true,
1428
+ openWorldHint: true
1429
+ },
1127
1430
  description: "Read the CRM-style activity log for a lead (calls, emails, meetings \u2014 actions performed by humans or prior agent runs). When to use: before contacting the lead, to avoid duplicating outreach the team already did. When NOT to use: when the lead summary's prospecting_actions_count is 0.",
1128
1431
  inputSchema: {
1129
1432
  type: "object",
@@ -1132,7 +1435,8 @@ var getProspectingActions = {
1132
1435
  count: { type: "number", description: "Items per page (1-200, default 20)" },
1133
1436
  page: { type: "number", description: "Page number, 0-indexed (default 0)" }
1134
1437
  },
1135
- required: ["leadId"]
1438
+ required: ["leadId"],
1439
+ additionalProperties: false
1136
1440
  },
1137
1441
  execute: async (client, params) => {
1138
1442
  const count = params.count ?? 20;
@@ -1144,11 +1448,40 @@ var getProspectingActions = {
1144
1448
  // ../core/dist/tools/get-web-fetch.js
1145
1449
  var getWebFetch = {
1146
1450
  name: "leadbay_get_web_fetch",
1451
+ annotations: {
1452
+ title: "Read web-fetch result",
1453
+ readOnlyHint: true,
1454
+ destructiveHint: false,
1455
+ idempotentHint: true,
1456
+ openWorldHint: true
1457
+ },
1147
1458
  description: "Read the AI-generated web-research summary for a lead \u2014 company profile, business signals, prospecting clues, each with sources and 'hot' flags marking high-signal recent items. The content is dictioned by emoji-prefixed section labels in the raw API. When to use: when the agent already qualified this lead and wants the underlying research to reason from. When NOT to use: as the first read on a lead \u2014 the leadbay_research_lead composite bundles this with qualification answers and reshapes the dict into a stable array form.",
1148
1459
  inputSchema: {
1149
1460
  type: "object",
1150
1461
  properties: { leadId: { type: "string", description: "Lead UUID (required)" } },
1151
- required: ["leadId"]
1462
+ required: ["leadId"],
1463
+ additionalProperties: false
1464
+ },
1465
+ outputSchema: {
1466
+ type: "object",
1467
+ description: "Raw LeadWebFetchPayload as returned by /leads/{id}/web_fetch. Permissive shape \u2014 backend dict structure is documented in detail by leadbay_research_lead which reshapes it.",
1468
+ properties: {
1469
+ content: {
1470
+ description: "Backend dict content (object or string), or null when no fetch yet."
1471
+ },
1472
+ fetch_at: {
1473
+ description: "ISO timestamp of the most recent fetch (string or null)."
1474
+ },
1475
+ status: {
1476
+ type: "string",
1477
+ description: "'pending' | 'complete' | 'failed' (when present)."
1478
+ },
1479
+ signals: {
1480
+ type: "array",
1481
+ description: "Optional reshaped signals when the backend returns them.",
1482
+ items: { type: "object" }
1483
+ }
1484
+ }
1152
1485
  },
1153
1486
  execute: async (client, params) => {
1154
1487
  return await client.request("GET", `/leads/${params.leadId}/web_fetch`);
@@ -1158,8 +1491,15 @@ var getWebFetch = {
1158
1491
  // ../core/dist/tools/get-selection-ids.js
1159
1492
  var getSelectionIds = {
1160
1493
  name: "leadbay_get_selection_ids",
1494
+ annotations: {
1495
+ title: "Read selection ids",
1496
+ readOnlyHint: true,
1497
+ destructiveHint: false,
1498
+ idempotentHint: true,
1499
+ openWorldHint: true
1500
+ },
1161
1501
  description: "List the lead ids currently in the user's selection (the transient set that bulk operations like enrichment act on). When to use: to verify the selection state before/after bulk ops if a composite call has misbehaved. When NOT to use: in the normal flow \u2014 leadbay_enrich_titles manages selection lifecycle automatically (select \u2192 action \u2192 clear).",
1162
- inputSchema: { type: "object", properties: {} },
1502
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1163
1503
  execute: async (client) => {
1164
1504
  return await client.request("GET", "/leads/selection/ids");
1165
1505
  }
@@ -1168,8 +1508,15 @@ var getSelectionIds = {
1168
1508
  // ../core/dist/tools/get-enrichment-job-titles.js
1169
1509
  var getEnrichmentJobTitles = {
1170
1510
  name: "leadbay_get_enrichment_job_titles",
1511
+ annotations: {
1512
+ title: "Read enrichment job titles",
1513
+ readOnlyHint: true,
1514
+ destructiveHint: false,
1515
+ idempotentHint: true,
1516
+ openWorldHint: true
1517
+ },
1171
1518
  description: "List the actual job titles present across the leads currently in the user's selection \u2014 the candidate set the user can ask to enrich. When to use: after leadbay_select_leads, to know which titles are even available before launching a bulk enrichment. When NOT to use: standalone \u2014 the selection must already be populated, otherwise the result is an empty array. leadbay_enrich_titles wraps this whole flow when you don't need to inspect the title list manually.",
1172
- inputSchema: { type: "object", properties: {} },
1519
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
1173
1520
  execute: async (client) => {
1174
1521
  return await client.request("GET", "/leads/selection/enrichment/job_titles");
1175
1522
  }
@@ -2071,6 +2418,16 @@ function reconcileOneChunk(prep, chunk, matched, notImported) {
2071
2418
  }
2072
2419
  var importLeads = {
2073
2420
  name: "leadbay_import_leads",
2421
+ annotations: {
2422
+ title: "Import leads from list/file",
2423
+ readOnlyHint: false,
2424
+ destructiveHint: true,
2425
+ // Backend dedupes by domain/registry id; same input set ⇒ same lead set
2426
+ // (no duplicate leads are created). bulk-store also keys on the
2427
+ // input-hash → returns the same importId on retry.
2428
+ idempotentHint: true,
2429
+ openWorldHint: true
2430
+ },
2074
2431
  description: "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.\n\nTWO MODES:\n A) Domain-list shortcut \u2014 pass `domains: [{domain, name?}]`. The tool builds a 2-column CSV (LEAD_NAME, LEAD_WEBSITE) and imports with the default mapping. Output: { leads: [{domain, leadId, name}], not_imported: [{domain, reason}], importIds, _meta }.\n B) Custom records + mapping \u2014 pass `records: [{Col1, Col2, ...}]` plus `mappings.fields: {Col1: 'LEAD_NAME', Col2: 'LEAD_WEBSITE', ...}`. The tool synthesizes a CSV from the union of record keys (deterministic order) and POSTs the caller-supplied mapping to the wizard. mappings.fields must include LEAD_NAME or LEAD_WEBSITE (the resolver needs at least one). Output: { leads: [{rowId, domain?, leadId, name}], not_imported: [{rowId, domain?, reason}], importIds, _meta }. `rowId` round-trips your input order.\n\nPass exactly one of `domains` / `records`. Reserved column MCP_ROW_ID (any case) cannot appear in records or mappings \u2014 the tool injects it for stable reconciliation.\n\n\u26A0\uFE0F MUTATES USER STATE. Each call:\n - creates a row in the user's CRM-imports list (visible in the web UI)\n - touches onboarding state (startFileless, onboarding step \u2192 PROCESSING)\nSuitable for occasional automation. NOT suitable for high-cadence (>5 calls/day) \u2014 wait for the backend programmatic endpoint (issue: leadbay/backend prolonged-import-with-crawl).\n\n\u2139\uFE0F Monitor-tab membership: imported leads are NOT auto-promoted to the user's Monitor view. Lens-scoring decides \u2014 only above-threshold leads get `in_monitor: true` server-side.\n\nWhen to use: you have a list of company domains from another system (CRM, analytics, email correspondents) and need stable Leadbay leadIds; or you have CRM-shaped rows with custom columns (sector, location, status, etc.) and want to drive the wizard with explicit field mappings.\nWhen NOT to use: for prospect discovery (use leadbay_pull_leads); for one specific company's profile (use leadbay_research_company); when you can't tolerate the side effects above.\n\nCustom fields: pass org-defined custom field mappings as 'CUSTOM.<id>' (raw wire format) in `mappings.fields`, OR use the ergonomic `mappings.custom_fields` shorthand: `{ColName: 8}` (numeric id) or `{ColName: 'priority_test'}` (field name). Discover available custom fields via leadbay_list_mappable_fields.\n\nRequires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role on the Leadbay account; active billing.",
2075
2432
  write: true,
2076
2433
  version: "0.3.0",
@@ -2092,7 +2449,10 @@ var importLeads = {
2092
2449
  description: "Optional display name override; defaults to the domain."
2093
2450
  }
2094
2451
  },
2095
- required: ["domain"]
2452
+ required: ["domain"],
2453
+ // Domain entries are a closed shape — agents passing extra keys
2454
+ // (e.g., `leadId: "..."`) would silently no-op. Reject explicitly.
2455
+ additionalProperties: false
2096
2456
  }
2097
2457
  },
2098
2458
  records: {
@@ -2135,9 +2495,41 @@ var importLeads = {
2135
2495
  type: "number",
2136
2496
  description: `Overall cap across all phases (default ${DEFAULT_TOTAL_BUDGET_MS}ms).`
2137
2497
  }
2138
- }
2498
+ },
2139
2499
  // Neither field is "required" at the schema level; xor + presence is
2140
2500
  // enforced in execute() so we can produce specific error codes.
2501
+ additionalProperties: false
2502
+ },
2503
+ outputSchema: {
2504
+ type: "object",
2505
+ properties: {
2506
+ leads: {
2507
+ type: "array",
2508
+ description: "Imported leads. Domains mode: [{domain, leadId, name}]. Records mode: [{rowId, domain?, leadId, name}].",
2509
+ items: { type: "object" }
2510
+ },
2511
+ not_imported: {
2512
+ type: "array",
2513
+ description: "Inputs that did NOT yield a leadId. Each entry has a `reason` ('malformed', 'NO_MATCH', 'TIMEOUT', etc.) plus the input echo.",
2514
+ items: { type: "object" }
2515
+ },
2516
+ importIds: {
2517
+ type: "array",
2518
+ description: "Backend file-import handles (one per chunk of \u2264100 rows).",
2519
+ items: { type: "string" }
2520
+ },
2521
+ region: { type: "string" },
2522
+ cancelled: {
2523
+ type: "boolean",
2524
+ description: "True when ctx.signal aborted the call mid-flight."
2525
+ },
2526
+ dry_run: {
2527
+ type: "boolean",
2528
+ description: "True when dry_run:true was passed (preprocess only, no CRM commit)."
2529
+ },
2530
+ _meta: { type: "object" }
2531
+ },
2532
+ required: ["leads", "not_imported", "importIds", "region", "_meta"]
2141
2533
  },
2142
2534
  execute: async (client, params, ctx) => {
2143
2535
  const signal = ctx?.signal;
@@ -2349,6 +2741,13 @@ var PREVIEW_SAMPLE_CAP = 50;
2349
2741
  var PREPROCESS_BUDGET_MS = 6e4;
2350
2742
  var listMappableFields = {
2351
2743
  name: "leadbay_list_mappable_fields",
2744
+ annotations: {
2745
+ title: "List CRM-import mappable fields",
2746
+ readOnlyHint: true,
2747
+ destructiveHint: false,
2748
+ idempotentHint: true,
2749
+ openWorldHint: true
2750
+ },
2352
2751
  description: "List every CRM field the agent can target when calling leadbay_import_leads or leadbay_import_and_qualify. Returns two arrays: `standard_fields` (Leadbay's built-in StandardCrmFieldType enum \u2014 LEAD_NAME, LEAD_WEBSITE, LEAD_STATUS, contact + location + sector fields) and `custom_fields` (this org's user-defined fields \u2014 id, name, type, and the literal `mapping_value` you pass in `mappings.fields`). For custom fields, `mapping_value` is the wire-format string `CUSTOM.<id>` \u2014 pass it verbatim.\n\nOptional `for_records` param: pass a sample of CSV-shaped rows and the tool also runs the wizard's preprocess on them, attaching `mapping_hints` (per-column AI-confidence suggestions) and `custom_field_candidates` (custom fields that match unmapped columns by exact / case-insensitive / fuzzy name) to the response. Saves a separate preview round-trip when the agent already has data in hand.\n\nWhen to use: before authoring an import mapping, especially when the CSV has columns that aren't obvious matches for standard fields. When NOT to use: when you already know the mapping \u2014 this call is cheap (~50ms with no for_records, ~5\u201310s with) but unnecessary if the agent has already cached the catalog within the same conversation.",
2353
2752
  inputSchema: {
2354
2753
  type: "object",
@@ -2361,6 +2760,44 @@ var listMappableFields = {
2361
2760
  },
2362
2761
  additionalProperties: false
2363
2762
  },
2763
+ outputSchema: {
2764
+ type: "object",
2765
+ properties: {
2766
+ standard_fields: {
2767
+ type: "array",
2768
+ description: "Built-in StandardCrmFieldType entries (LEAD_NAME, LEAD_WEBSITE, contact + location + sector). Each: {name, description, mapping_value}.",
2769
+ items: { type: "object" }
2770
+ },
2771
+ custom_fields: {
2772
+ type: "array",
2773
+ description: "Org-defined custom fields. Each: {id, name, type, description, mapping_value:'CUSTOM.<id>'}.",
2774
+ items: { type: "object" }
2775
+ },
2776
+ mapping_hints: {
2777
+ type: "array",
2778
+ description: "Per-column AI-confidence suggestions (only when for_records was passed). Each: {column, target, confidence, reason}.",
2779
+ items: { type: "object" }
2780
+ },
2781
+ custom_field_candidates: {
2782
+ type: "array",
2783
+ description: "Custom fields matching unmapped columns by exact / case-insensitive / fuzzy name (only when for_records was passed).",
2784
+ items: { type: "object" }
2785
+ },
2786
+ sample_rows: {
2787
+ type: "array",
2788
+ description: "First few rows of the preprocessed sample (only when for_records was passed).",
2789
+ items: { type: "object" }
2790
+ },
2791
+ notes: {
2792
+ type: "array",
2793
+ description: "Operator notes (e.g., preprocess timeout, sample-size truncation).",
2794
+ items: { type: "string" }
2795
+ },
2796
+ region: { type: "string" },
2797
+ _meta: { type: "object" }
2798
+ },
2799
+ required: ["standard_fields", "custom_fields", "region", "_meta"]
2800
+ },
2364
2801
  execute: async (client, params, ctx) => {
2365
2802
  const signal = ctx?.signal;
2366
2803
  const customs = await client.request("GET", "/crm/custom_fields");
@@ -2466,6 +2903,13 @@ function coerceCsvValue(v) {
2466
2903
  // ../core/dist/tools/select-leads.js
2467
2904
  var selectLeads = {
2468
2905
  name: "leadbay_select_leads",
2906
+ annotations: {
2907
+ title: "Select leads",
2908
+ readOnlyHint: false,
2909
+ destructiveHint: true,
2910
+ idempotentHint: true,
2911
+ openWorldHint: true
2912
+ },
2469
2913
  description: "Add leads to the user's transient selection (used by selection-scoped bulk operations). When to use: low-level. The user's selection is a per-token global state \u2014 be careful when invoking directly. When NOT to use: in normal flow \u2014 leadbay_enrich_titles wraps select \u2192 action \u2192 clear in one call with proper Mutex protection. Calling this directly without acquiring the selection lock can clobber concurrent composite calls.",
2470
2914
  optional: true,
2471
2915
  write: true,
@@ -2480,7 +2924,18 @@ var selectLeads = {
2480
2924
  maxItems: 1e3
2481
2925
  }
2482
2926
  },
2483
- required: ["leadIds"]
2927
+ required: ["leadIds"],
2928
+ additionalProperties: false
2929
+ },
2930
+ outputSchema: {
2931
+ type: "object",
2932
+ properties: {
2933
+ selected: {
2934
+ type: "number",
2935
+ description: "How many leadIds the call added to the selection (echoes input length)."
2936
+ }
2937
+ },
2938
+ required: ["selected"]
2484
2939
  },
2485
2940
  execute: async (client, params) => {
2486
2941
  const qs = params.leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
@@ -2492,6 +2947,13 @@ var selectLeads = {
2492
2947
  // ../core/dist/tools/deselect-leads.js
2493
2948
  var deselectLeads = {
2494
2949
  name: "leadbay_deselect_leads",
2950
+ annotations: {
2951
+ title: "Deselect leads",
2952
+ readOnlyHint: false,
2953
+ destructiveHint: true,
2954
+ idempotentHint: true,
2955
+ openWorldHint: true
2956
+ },
2495
2957
  description: "Remove leads from the user's transient selection. When to use: when narrowing a previously-built selection without clearing it entirely. When NOT to use: in normal flow \u2014 leadbay_enrich_titles handles selection lifecycle.",
2496
2958
  optional: true,
2497
2959
  write: true,
@@ -2505,7 +2967,8 @@ var deselectLeads = {
2505
2967
  minItems: 1
2506
2968
  }
2507
2969
  },
2508
- required: ["leadIds"]
2970
+ required: ["leadIds"],
2971
+ additionalProperties: false
2509
2972
  },
2510
2973
  execute: async (client, params) => {
2511
2974
  const qs = params.leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
@@ -2517,10 +2980,17 @@ var deselectLeads = {
2517
2980
  // ../core/dist/tools/clear-selection.js
2518
2981
  var clearSelection = {
2519
2982
  name: "leadbay_clear_selection",
2983
+ annotations: {
2984
+ title: "Clear selection",
2985
+ readOnlyHint: false,
2986
+ destructiveHint: true,
2987
+ idempotentHint: true,
2988
+ openWorldHint: true
2989
+ },
2520
2990
  description: "Clear the user's transient selection. When to use: cleanup after manual selection work, or recovery from a stuck composite. When NOT to use: in normal flow \u2014 composites clear in their own finally blocks.",
2521
2991
  optional: true,
2522
2992
  write: true,
2523
- inputSchema: { type: "object", properties: {} },
2993
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
2524
2994
  execute: async (client) => {
2525
2995
  await client.requestVoid("POST", "/leads/selection/clear");
2526
2996
  return { cleared: true };
@@ -2530,13 +3000,21 @@ var clearSelection = {
2530
3000
  // ../core/dist/tools/set-active-lens.js
2531
3001
  var setActiveLens = {
2532
3002
  name: "leadbay_set_active_lens",
3003
+ annotations: {
3004
+ title: "Set active lens",
3005
+ readOnlyHint: false,
3006
+ destructiveHint: true,
3007
+ idempotentHint: true,
3008
+ openWorldHint: true
3009
+ },
2533
3010
  description: "Mark a lens as last-used. Subsequent /me reads return it as last_requested_lens, so all composite tools default to it. When to use: after the user explicitly switched contexts (e.g. created a new lens via leadbay_create_lens). When NOT to use: in normal flow \u2014 leadbay_pull_leads and leadbay_adjust_audience auto-set the right lens.",
2534
3011
  optional: true,
2535
3012
  write: true,
2536
3013
  inputSchema: {
2537
3014
  type: "object",
2538
3015
  properties: { lensId: { type: "number", description: "Lens id (required)" } },
2539
- required: ["lensId"]
3016
+ required: ["lensId"],
3017
+ additionalProperties: false
2540
3018
  },
2541
3019
  execute: async (client, params) => {
2542
3020
  await client.requestVoid("POST", `/lenses/${params.lensId}/update_last_requested`);
@@ -2549,6 +3027,13 @@ var setActiveLens = {
2549
3027
  // ../core/dist/tools/create-lens.js
2550
3028
  var createLens = {
2551
3029
  name: "leadbay_create_lens",
3030
+ annotations: {
3031
+ title: "Create a new lens",
3032
+ readOnlyHint: false,
3033
+ destructiveHint: true,
3034
+ idempotentHint: false,
3035
+ openWorldHint: true
3036
+ },
2552
3037
  description: "Create a new user-level lens by cloning an existing lens's filter/scoring as the starting point. When to use: when adjust_audience determined the current lens cannot be edited (e.g. it's the org default). When NOT to use: to update an existing lens \u2014 use leadbay_update_lens or leadbay_update_lens_filter.",
2553
3038
  optional: true,
2554
3039
  write: true,
@@ -2559,7 +3044,21 @@ var createLens = {
2559
3044
  name: { type: "string", description: "Display name for the new lens" },
2560
3045
  description: { type: "string" }
2561
3046
  },
2562
- required: ["base", "name"]
3047
+ required: ["base", "name"],
3048
+ additionalProperties: false
3049
+ },
3050
+ outputSchema: {
3051
+ type: "object",
3052
+ description: "Full LensPayload as returned by the backend. Permissive shape \u2014 backend may add fields over time.",
3053
+ properties: {
3054
+ id: { type: "number", description: "New lens id." },
3055
+ name: { type: "string" },
3056
+ description: { type: ["string", "null"] },
3057
+ is_default: { type: "boolean" },
3058
+ is_last_active: { type: "boolean" },
3059
+ user_id: { type: ["string", "number", "null"] }
3060
+ },
3061
+ required: ["id", "name"]
2563
3062
  },
2564
3063
  execute: async (client, params) => {
2565
3064
  const lens = await client.request("POST", "/lenses", {
@@ -2575,6 +3074,13 @@ var createLens = {
2575
3074
  // ../core/dist/tools/update-lens.js
2576
3075
  var updateLens = {
2577
3076
  name: "leadbay_update_lens",
3077
+ annotations: {
3078
+ title: "Update a lens",
3079
+ readOnlyHint: false,
3080
+ destructiveHint: true,
3081
+ idempotentHint: true,
3082
+ openWorldHint: true
3083
+ },
2578
3084
  description: "Update lens metadata (name, description, mode flags). Does NOT change the audience filter \u2014 use leadbay_update_lens_filter for that. When to use: rename a lens or toggle multi_product_mode/use_hq_only. When NOT to use: to change which leads the lens shows \u2014 that's a filter operation.",
2579
3085
  optional: true,
2580
3086
  write: true,
@@ -2587,7 +3093,8 @@ var updateLens = {
2587
3093
  multi_product_mode: { type: "boolean" },
2588
3094
  use_hq_only: { type: "boolean" }
2589
3095
  },
2590
- required: ["lensId"]
3096
+ required: ["lensId"],
3097
+ additionalProperties: false
2591
3098
  },
2592
3099
  execute: async (client, params) => {
2593
3100
  const { lensId, ...body } = params;
@@ -2600,6 +3107,13 @@ var updateLens = {
2600
3107
  // ../core/dist/tools/update-lens-filter.js
2601
3108
  var updateLensFilter = {
2602
3109
  name: "leadbay_update_lens_filter",
3110
+ annotations: {
3111
+ title: "Update lens filter",
3112
+ readOnlyHint: false,
3113
+ destructiveHint: true,
3114
+ idempotentHint: true,
3115
+ openWorldHint: true
3116
+ },
2603
3117
  description: "Replace the audience filter (sectors, sizes, locations) on a lens. Body is the full Filter object \u2014 this is a REPLACE, not a merge. Returns 400 'default_lens' if applied to the org default lens (clone it first). When to use: low-level mutation when you've already prepared the merged filter. When NOT to use: from agent flow \u2014 use leadbay_adjust_audience, which handles draft-vs-direct routing, permission fallback, and the merge logic so unrelated criteria aren't dropped.",
2604
3118
  optional: true,
2605
3119
  write: true,
@@ -2616,7 +3130,8 @@ var updateLensFilter = {
2616
3130
  description: "If true, return the call shape that WOULD be sent without contacting the backend"
2617
3131
  }
2618
3132
  },
2619
- required: ["lensId", "filter"]
3133
+ required: ["lensId", "filter"],
3134
+ additionalProperties: false
2620
3135
  },
2621
3136
  execute: async (client, params) => {
2622
3137
  if (params.dry_run) {
@@ -2638,13 +3153,21 @@ var updateLensFilter = {
2638
3153
  // ../core/dist/tools/create-lens-draft.js
2639
3154
  var createLensDraft = {
2640
3155
  name: "leadbay_create_lens_draft",
3156
+ annotations: {
3157
+ title: "Create a lens draft",
3158
+ readOnlyHint: false,
3159
+ destructiveHint: true,
3160
+ idempotentHint: false,
3161
+ openWorldHint: true
3162
+ },
2641
3163
  description: "Create (or fetch existing) draft of an org-level lens. Idempotent \u2014 same user calling twice returns the same draft. The returned lens has draft_of set to the original lens id. When to use: when a non-admin needs to modify an org-level lens \u2014 make a draft, edit the draft. When NOT to use: from agent flow \u2014 leadbay_adjust_audience handles the draft-routing transparently.",
2642
3164
  optional: true,
2643
3165
  write: true,
2644
3166
  inputSchema: {
2645
3167
  type: "object",
2646
3168
  properties: { lensId: { type: "number", description: "Lens id of the org-level lens to draft" } },
2647
- required: ["lensId"]
3169
+ required: ["lensId"],
3170
+ additionalProperties: false
2648
3171
  },
2649
3172
  execute: async (client, params) => {
2650
3173
  return await client.request("POST", `/lenses/${params.lensId}/draft`);
@@ -2654,13 +3177,21 @@ var createLensDraft = {
2654
3177
  // ../core/dist/tools/promote-lens.js
2655
3178
  var promoteLens = {
2656
3179
  name: "leadbay_promote_lens",
3180
+ annotations: {
3181
+ title: "Promote a lens draft to active",
3182
+ readOnlyHint: false,
3183
+ destructiveHint: true,
3184
+ idempotentHint: false,
3185
+ openWorldHint: true
3186
+ },
2657
3187
  description: "Promote a user-level lens (or draft) to org-level so all teammates see it. Admin-only. When to use: rare \u2014 when an admin user has built a lens (or refined a draft) and wants to share it org-wide. When NOT to use: as a non-admin (will fail with 403); for personal lens changes (those stay user-scoped).",
2658
3188
  optional: true,
2659
3189
  write: true,
2660
3190
  inputSchema: {
2661
3191
  type: "object",
2662
3192
  properties: { lensId: { type: "number" } },
2663
- required: ["lensId"]
3193
+ required: ["lensId"],
3194
+ additionalProperties: false
2664
3195
  },
2665
3196
  execute: async (client, params) => {
2666
3197
  await client.requestVoid("POST", `/lenses/${params.lensId}/promote`);
@@ -2672,6 +3203,13 @@ var promoteLens = {
2672
3203
  // ../core/dist/tools/set-user-prompt.js
2673
3204
  var setUserPrompt = {
2674
3205
  name: "leadbay_set_user_prompt",
3206
+ annotations: {
3207
+ title: "Set the user prompt",
3208
+ readOnlyHint: false,
3209
+ destructiveHint: true,
3210
+ idempotentHint: true,
3211
+ openWorldHint: true
3212
+ },
2675
3213
  description: "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). When to use: low-level. When NOT to use: from agent flow \u2014 use leadbay_refine_prompt, which polls for follow-up clarifications.",
2676
3214
  optional: true,
2677
3215
  write: true,
@@ -2684,7 +3222,8 @@ var setUserPrompt = {
2684
3222
  description: "If true, return the call shape that WOULD be sent without contacting the backend"
2685
3223
  }
2686
3224
  },
2687
- required: ["prompt"]
3225
+ required: ["prompt"],
3226
+ additionalProperties: false
2688
3227
  },
2689
3228
  execute: async (client, params) => {
2690
3229
  const orgId = await client.resolveOrgId();
@@ -2709,10 +3248,17 @@ var setUserPrompt = {
2709
3248
  // ../core/dist/tools/clear-user-prompt.js
2710
3249
  var clearUserPrompt = {
2711
3250
  name: "leadbay_clear_user_prompt",
3251
+ annotations: {
3252
+ title: "Clear the user prompt",
3253
+ readOnlyHint: false,
3254
+ destructiveHint: true,
3255
+ idempotentHint: true,
3256
+ openWorldHint: true
3257
+ },
2712
3258
  description: "Remove the org's intelligence-refinement prompt (revert to AI-only generation). Admin-only. Triggers full intelligence regeneration. When to use: when a refinement turned out to be the wrong direction. When NOT to use: to replace with a different prompt \u2014 just call leadbay_refine_prompt; that overwrites.",
2713
3259
  optional: true,
2714
3260
  write: true,
2715
- inputSchema: { type: "object", properties: {} },
3261
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
2716
3262
  execute: async (client) => {
2717
3263
  const orgId = await client.resolveOrgId();
2718
3264
  await client.requestVoid("DELETE", `/organizations/${orgId}/user_prompt`);
@@ -2724,6 +3270,13 @@ var clearUserPrompt = {
2724
3270
  // ../core/dist/tools/pick-clarification.js
2725
3271
  var pickClarification = {
2726
3272
  name: "leadbay_pick_clarification",
3273
+ annotations: {
3274
+ title: "Pick a clarification answer",
3275
+ readOnlyHint: false,
3276
+ destructiveHint: true,
3277
+ idempotentHint: false,
3278
+ openWorldHint: true
3279
+ },
2727
3280
  description: "Answer the pending clarification question \u2014 either by picking one of the offered options (option_id) or by typing a free-text answer. The answer is stored as the new user_prompt and triggers regeneration. Admin-only. When to use: low-level. When NOT to use: from agent flow \u2014 use leadbay_answer_clarification.",
2728
3281
  optional: true,
2729
3282
  write: true,
@@ -2732,7 +3285,18 @@ var pickClarification = {
2732
3285
  properties: {
2733
3286
  option_id: { type: "string", description: "Id of one of the clarification's options" },
2734
3287
  text_answer: { type: "string", description: "Free-text answer (overrides option_id if both are set)" }
2735
- }
3288
+ },
3289
+ additionalProperties: false
3290
+ },
3291
+ outputSchema: {
3292
+ type: "object",
3293
+ properties: {
3294
+ answered: {
3295
+ type: "boolean",
3296
+ description: "True when the answer was recorded; intelligence regeneration begins."
3297
+ }
3298
+ },
3299
+ required: ["answered"]
2736
3300
  },
2737
3301
  execute: async (client, params) => {
2738
3302
  if (!params.option_id && !params.text_answer) {
@@ -2753,10 +3317,17 @@ var pickClarification = {
2753
3317
  // ../core/dist/tools/dismiss-clarification.js
2754
3318
  var dismissClarification = {
2755
3319
  name: "leadbay_dismiss_clarification",
3320
+ annotations: {
3321
+ title: "Dismiss a clarification",
3322
+ readOnlyHint: false,
3323
+ destructiveHint: true,
3324
+ idempotentHint: false,
3325
+ openWorldHint: true
3326
+ },
2756
3327
  description: "Dismiss the pending clarification without answering. Leadbay proceeds with its best guess. Admin-only. When to use: when the user explicitly doesn't want to answer the disambiguation. When NOT to use: as a default \u2014 answering with even a free-text reason gives Leadbay better signal.",
2757
3328
  optional: true,
2758
3329
  write: true,
2759
- inputSchema: { type: "object", properties: {} },
3330
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
2760
3331
  execute: async (client) => {
2761
3332
  const orgId = await client.resolveOrgId();
2762
3333
  await client.requestVoid("POST", `/organizations/${orgId}/dismiss_clarification`);
@@ -2779,6 +3350,13 @@ var EPILOGUE_LABEL_MAP = {
2779
3350
  };
2780
3351
  var setEpilogueStatus = {
2781
3352
  name: "leadbay_set_epilogue_status",
3353
+ annotations: {
3354
+ title: "Set lead epilogue status",
3355
+ readOnlyHint: false,
3356
+ destructiveHint: true,
3357
+ idempotentHint: true,
3358
+ openWorldHint: true
3359
+ },
2782
3360
  description: "Bulk-set the outreach progress (epilogue) status across a set of leads. Status values: STILL_CHASING, COULD_NOT_REACH_STILL_TRYING, INTEREST_VALIDATED_OR_MEETING_PLANED ('meeting booked'), NOT_INTERESTED_LOST (short labels accepted; mapped to the EPILOGUE_* enum). Up to 1000 leads per call. When to use: low-level. 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.",
2783
3361
  optional: true,
2784
3362
  write: true,
@@ -2795,7 +3373,8 @@ var setEpilogueStatus = {
2795
3373
  description: "One of: STILL_CHASING, COULD_NOT_REACH_STILL_TRYING, INTEREST_VALIDATED_OR_MEETING_PLANED, NOT_INTERESTED_LOST"
2796
3374
  }
2797
3375
  },
2798
- required: ["lead_ids", "status"]
3376
+ required: ["lead_ids", "status"],
3377
+ additionalProperties: false
2799
3378
  },
2800
3379
  execute: async (client, params) => {
2801
3380
  const wire = EPILOGUE_LABEL_MAP[params.status];
@@ -2818,6 +3397,13 @@ var setEpilogueStatus = {
2818
3397
  // ../core/dist/tools/remove-epilogue.js
2819
3398
  var removeEpilogue = {
2820
3399
  name: "leadbay_remove_epilogue",
3400
+ annotations: {
3401
+ title: "Remove lead epilogue",
3402
+ readOnlyHint: false,
3403
+ destructiveHint: true,
3404
+ idempotentHint: true,
3405
+ openWorldHint: true
3406
+ },
2821
3407
  description: "Bulk-clear the epilogue status from a set of leads. When to use: when an outreach action was logged in error and needs to be undone. When NOT to use: to change status \u2014 call leadbay_set_epilogue_status with the new status (it overwrites).",
2822
3408
  optional: true,
2823
3409
  write: true,
@@ -2830,7 +3416,8 @@ var removeEpilogue = {
2830
3416
  description: "Lead UUIDs"
2831
3417
  }
2832
3418
  },
2833
- required: ["lead_ids"]
3419
+ required: ["lead_ids"],
3420
+ additionalProperties: false
2834
3421
  },
2835
3422
  execute: async (client, params) => {
2836
3423
  await client.requestVoid("POST", "/leads/remove_epilogue", {
@@ -2843,6 +3430,13 @@ var removeEpilogue = {
2843
3430
  // ../core/dist/tools/preview-bulk-enrichment.js
2844
3431
  var previewBulkEnrichment = {
2845
3432
  name: "leadbay_preview_bulk_enrichment",
3433
+ annotations: {
3434
+ title: "Preview bulk enrichment cost",
3435
+ readOnlyHint: true,
3436
+ destructiveHint: false,
3437
+ idempotentHint: true,
3438
+ openWorldHint: true
3439
+ },
2846
3440
  description: "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. When to use: between selecting leads and launching, to know what the enrichment will cost. When NOT to use: from agent flow \u2014 leadbay_enrich_titles wraps preview + launch with the right safety checks.",
2847
3441
  optional: true,
2848
3442
  write: true,
@@ -2855,7 +3449,8 @@ var previewBulkEnrichment = {
2855
3449
  description: "Job titles to enrich (matched against contacts in selected leads)"
2856
3450
  }
2857
3451
  },
2858
- required: ["titles"]
3452
+ required: ["titles"],
3453
+ additionalProperties: false
2859
3454
  },
2860
3455
  execute: async (client, params) => {
2861
3456
  return await client.request("POST", "/leads/selection/enrichment/preview", { titles: params.titles });
@@ -2865,6 +3460,13 @@ var previewBulkEnrichment = {
2865
3460
  // ../core/dist/tools/launch-bulk-enrichment.js
2866
3461
  var launchBulkEnrichment = {
2867
3462
  name: "leadbay_launch_bulk_enrichment",
3463
+ annotations: {
3464
+ title: "Launch bulk enrichment",
3465
+ readOnlyHint: false,
3466
+ destructiveHint: true,
3467
+ idempotentHint: true,
3468
+ openWorldHint: true
3469
+ },
2868
3470
  description: "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. When to use: low-level. When NOT to use: from agent flow \u2014 leadbay_enrich_titles handles selection lifecycle, preview, launch, and cleanup.",
2869
3471
  optional: true,
2870
3472
  write: true,
@@ -2879,7 +3481,8 @@ var launchBulkEnrichment = {
2879
3481
  description: "If true, return the call shape WITHOUT contacting the backend"
2880
3482
  }
2881
3483
  },
2882
- required: ["titles"]
3484
+ required: ["titles"],
3485
+ additionalProperties: false
2883
3486
  },
2884
3487
  execute: async (client, params) => {
2885
3488
  const email = params.email ?? true;
@@ -2920,6 +3523,13 @@ var launchBulkEnrichment = {
2920
3523
  // ../core/dist/composite/research-company.js
2921
3524
  var researchCompany = {
2922
3525
  name: "leadbay_research_company",
3526
+ annotations: {
3527
+ title: "Research a company by name",
3528
+ readOnlyHint: true,
3529
+ destructiveHint: false,
3530
+ idempotentHint: true,
3531
+ openWorldHint: true
3532
+ },
2923
3533
  description: "Deep-dive research on a specific company by NAME (fuzzy match against the active lens's wishlist). When to use: when the user references a company by name and you don't yet have its lead_id. 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).",
2924
3534
  inputSchema: {
2925
3535
  type: "object",
@@ -2932,7 +3542,39 @@ var researchCompany = {
2932
3542
  type: "string",
2933
3543
  description: "Lead UUID if already known (one of companyName or leadId required). Takes precedence over companyName."
2934
3544
  }
2935
- }
3545
+ },
3546
+ additionalProperties: false
3547
+ },
3548
+ outputSchema: {
3549
+ type: "object",
3550
+ properties: {
3551
+ lead: {
3552
+ type: "object",
3553
+ description: "Lead profile basics (id, name, score, ai_agent_lead_score, location, description, short_description, size, website, logo, ai_summary, tags, phone_numbers, keywords, contacts_count, recommended_contact_title, recommended_contact, web_fetch_in_progress)."
3554
+ },
3555
+ qualification: {
3556
+ type: ["array", "null"],
3557
+ description: "Per-question AI qualification answers ({question, score, response, computed_at, outdated_at}), or null if none.",
3558
+ items: { type: "object" }
3559
+ },
3560
+ contacts: {
3561
+ type: "array",
3562
+ description: "Merged org + paid contacts. Each: {id, first_name, last_name, email, phone_number, linkedin_page, job_title, recommended, enrichment, source:'org'|'paid'}.",
3563
+ items: { type: "object" }
3564
+ },
3565
+ web_insights: {
3566
+ description: "Latest /web_fetch content (string) or null when no fetch is available."
3567
+ },
3568
+ web_insights_fetched_at: {
3569
+ description: "ISO timestamp of the latest /web_fetch (string) or null."
3570
+ },
3571
+ recent_activities: {
3572
+ type: "array",
3573
+ description: "Recent activities for this lead (top 20). Each is the activity payload as returned by /leads/{id}/activities.",
3574
+ items: { type: "object" }
3575
+ }
3576
+ },
3577
+ required: ["lead", "contacts", "recent_activities"]
2936
3578
  },
2937
3579
  execute: async (client, params, ctx) => {
2938
3580
  if (!params.leadId && !params.companyName) {
@@ -2965,6 +3607,13 @@ var researchCompany = {
2965
3607
  // ../core/dist/composite/prepare-outreach.js
2966
3608
  var prepareOutreach = {
2967
3609
  name: "leadbay_prepare_outreach",
3610
+ annotations: {
3611
+ title: "Prepare outreach package for a lead",
3612
+ readOnlyHint: true,
3613
+ destructiveHint: false,
3614
+ idempotentHint: true,
3615
+ openWorldHint: true
3616
+ },
2968
3617
  description: "Prepare an outreach package for a single lead: recommended contact + enriched contact details + AI summary. When to use: when the agent is about to draft outreach for ONE specific lead and needs the contact's email/phone. 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).",
2969
3618
  optional: true,
2970
3619
  inputSchema: {
@@ -2979,11 +3628,44 @@ var prepareOutreach = {
2979
3628
  description: "If true and credits available, trigger enrichment on the recommended contact (default: false). Enrichment is async \u2014 poll leadbay_get_contacts after ~60s."
2980
3629
  }
2981
3630
  },
2982
- required: ["leadId"]
3631
+ required: ["leadId"],
3632
+ additionalProperties: false
2983
3633
  },
2984
- execute: async (client, params, ctx) => {
2985
- const contactsResult = await getContacts.execute(client, { leadId: params.leadId }, ctx);
2986
- const contacts = contactsResult.contacts;
3634
+ outputSchema: {
3635
+ type: "object",
3636
+ properties: {
3637
+ lead: {
3638
+ type: ["object", "null"],
3639
+ description: "Short lead summary for outreach context: {name, ai_summary, website}. Null if /lead profile fetch failed.",
3640
+ properties: {
3641
+ name: { type: "string" },
3642
+ ai_summary: { type: ["string", "null"] },
3643
+ website: { type: ["string", "null"] }
3644
+ }
3645
+ },
3646
+ recommended_contact: {
3647
+ type: ["object", "null"],
3648
+ description: "Best contact to outreach to ({id, name, job_title, email, phone_number, linkedin_page}). Null when no contacts known."
3649
+ },
3650
+ other_contacts_count: {
3651
+ type: "number",
3652
+ description: "How many other contacts exist beyond the recommended one (so the agent knows there's more to discover via leadbay_get_contacts)."
3653
+ },
3654
+ enrichment: {
3655
+ type: "object",
3656
+ description: "Status of opt-in enrichment (only set when enrich:true was passed): {triggered, error, hint}.",
3657
+ properties: {
3658
+ triggered: { type: "boolean" },
3659
+ error: { type: ["string", "null"] },
3660
+ hint: { type: ["string", "null"] }
3661
+ }
3662
+ }
3663
+ },
3664
+ required: ["recommended_contact", "other_contacts_count", "enrichment"]
3665
+ },
3666
+ execute: async (client, params, ctx) => {
3667
+ const contactsResult = await getContacts.execute(client, { leadId: params.leadId }, ctx);
3668
+ const contacts = contactsResult.contacts;
2987
3669
  const recommended = contacts.find((c) => c.recommended) ?? contacts[0];
2988
3670
  let enrichmentTriggered = false;
2989
3671
  let enrichmentError = null;
@@ -3040,6 +3722,13 @@ function summarise(responses) {
3040
3722
  }
3041
3723
  var pullLeads = {
3042
3724
  name: "leadbay_pull_leads",
3725
+ annotations: {
3726
+ title: "Pull fresh Leadbay leads",
3727
+ readOnlyHint: true,
3728
+ destructiveHint: false,
3729
+ idempotentHint: true,
3730
+ openWorldHint: true
3731
+ },
3043
3732
  description: "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. 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. 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. 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 (which is removed in v0.2.0).",
3044
3733
  inputSchema: {
3045
3734
  type: "object",
@@ -3054,7 +3743,57 @@ var pullLeads = {
3054
3743
  type: "boolean",
3055
3744
  description: "If true, include the full set of lead-summary fields. Default false: returns the trimmed agent-friendly form."
3056
3745
  }
3057
- }
3746
+ },
3747
+ additionalProperties: false
3748
+ },
3749
+ outputSchema: {
3750
+ type: "object",
3751
+ properties: {
3752
+ lens: {
3753
+ type: "object",
3754
+ description: "Lens metadata (id of the lens that was queried).",
3755
+ properties: { id: { type: "number" } }
3756
+ },
3757
+ leads: {
3758
+ type: "array",
3759
+ description: "The page of leads. In default mode (verbose:false) each lead is the trimmed agent-friendly shape; in verbose:true the full LeadPayload.",
3760
+ items: { type: "object" }
3761
+ },
3762
+ pagination: {
3763
+ type: "object",
3764
+ description: "page (0-indexed), pages (total), total (item count).",
3765
+ properties: {
3766
+ page: { type: "number" },
3767
+ pages: { type: "number" },
3768
+ total: { type: "number" }
3769
+ }
3770
+ },
3771
+ has_more: {
3772
+ type: "boolean",
3773
+ description: "True if at least one more page exists. Spec-aligned pagination metadata."
3774
+ },
3775
+ next_page: {
3776
+ type: ["number", "null"],
3777
+ description: "0-indexed next page number, or null on the last page."
3778
+ },
3779
+ computing_wishlist: {
3780
+ type: "boolean",
3781
+ description: "True if Leadbay is still rebuilding this lens's wishlist."
3782
+ },
3783
+ computing_scores: {
3784
+ type: "boolean",
3785
+ description: "True if scoring is still running."
3786
+ },
3787
+ _meta: {
3788
+ type: "object",
3789
+ description: "Operator context: region + last-call latency.",
3790
+ properties: {
3791
+ region: { type: "string" },
3792
+ latency_ms: { type: ["number", "null"] }
3793
+ }
3794
+ }
3795
+ },
3796
+ required: ["lens", "leads", "pagination"]
3058
3797
  },
3059
3798
  execute: async (client, params, ctx) => {
3060
3799
  const lensId = params.lensId ?? await client.resolveDefaultLens();
@@ -3103,6 +3842,10 @@ var pullLeads = {
3103
3842
  epilogue_actions_count: lead.epilogue_actions_count ?? 0,
3104
3843
  prospecting_actions_count: lead.prospecting_actions_count ?? 0
3105
3844
  };
3845
+ const totalPages = res.pagination?.pages ?? 0;
3846
+ const currentPage = res.pagination?.page ?? page;
3847
+ const hasMore = currentPage < totalPages - 1;
3848
+ const nextPage = hasMore ? currentPage + 1 : null;
3106
3849
  return {
3107
3850
  lens: { id: lensId },
3108
3851
  leads: res.items.map((lead) => ({
@@ -3110,6 +3853,8 @@ var pullLeads = {
3110
3853
  qualification_summary: summaryMap.get(lead.id) ?? null
3111
3854
  })),
3112
3855
  pagination: res.pagination,
3856
+ has_more: hasMore,
3857
+ next_page: nextPage,
3113
3858
  computing_wishlist: res.computing_wishlist,
3114
3859
  computing_scores: res.computing_scores,
3115
3860
  _meta: {
@@ -3121,6 +3866,86 @@ var pullLeads = {
3121
3866
  };
3122
3867
 
3123
3868
  // ../core/dist/composite/research-lead.js
3869
+ function renderResearchLeadMarkdown(shape) {
3870
+ const out = [];
3871
+ const firm = shape.firmographics ?? {};
3872
+ const name = firm.name ?? "(unnamed lead)";
3873
+ out.push(`# ${name}`);
3874
+ if (firm.website)
3875
+ out.push(`Website: ${firm.website}`);
3876
+ if (firm.location)
3877
+ out.push(`Location: ${firm.location}`);
3878
+ if (typeof firm.score === "number" || firm.score === null) {
3879
+ const aiScore = firm.ai_agent_lead_score;
3880
+ out.push(`Score: ${firm.score ?? "\u2014"}` + (aiScore != null ? ` \xB7 AI: ${aiScore}` : ""));
3881
+ }
3882
+ if (firm.short_description)
3883
+ out.push(`
3884
+ ${firm.short_description}`);
3885
+ const qualification = Array.isArray(shape.qualification) ? shape.qualification : [];
3886
+ if (qualification.length > 0) {
3887
+ out.push(`
3888
+ ## Qualification`);
3889
+ for (const q of qualification) {
3890
+ const score = q.boost_score != null ? `${q.boost_score}` : "\u2014";
3891
+ const resp = q.response ? String(q.response).slice(0, 200) : "\u2014";
3892
+ out.push(`- **${q.question}** (boost ${score}): ${resp}`);
3893
+ }
3894
+ }
3895
+ const signals = Array.isArray(shape.signals) ? shape.signals : [];
3896
+ if (signals.length > 0) {
3897
+ out.push(`
3898
+ ## Signals`);
3899
+ for (const sec of signals) {
3900
+ const label = sec.section_label ?? "section";
3901
+ out.push(`### ${sec.section_emoji ?? ""} ${label}`.trim());
3902
+ const entries = Array.isArray(sec.entries) ? sec.entries : [];
3903
+ for (const e of entries.slice(0, 5)) {
3904
+ const text = e.text ?? e.summary ?? JSON.stringify(e).slice(0, 200);
3905
+ const hot = e.hot === true ? " \u{1F525}" : "";
3906
+ out.push(`- ${text}${hot}`);
3907
+ }
3908
+ if (entries.length > 5)
3909
+ out.push(`- _${entries.length - 5} more \u2026_`);
3910
+ }
3911
+ }
3912
+ const contacts = shape.contacts ?? {};
3913
+ const enriched = Array.isArray(contacts.enriched) ? contacts.enriched : [];
3914
+ if (enriched.length > 0) {
3915
+ out.push(`
3916
+ ## Contacts (enriched)`);
3917
+ for (const c of enriched.slice(0, 10)) {
3918
+ const fn = c.first_name ?? "";
3919
+ const ln = c.last_name ?? "";
3920
+ const title = c.job_title ?? "\u2014";
3921
+ const email = c.email ?? "no email";
3922
+ out.push(`- **${(fn + " " + ln).trim() || "(unknown)"}** \u2014 ${title} \xB7 ${email}`);
3923
+ }
3924
+ }
3925
+ const engagement = shape.engagement ?? {};
3926
+ const counts = [
3927
+ ["notes", engagement.notes_count],
3928
+ ["epilogue", engagement.epilogue_actions_count],
3929
+ ["prospecting", engagement.prospecting_actions_count]
3930
+ ];
3931
+ const activeCounts = counts.filter(([, v]) => typeof v === "number" && v > 0);
3932
+ if (activeCounts.length > 0 || engagement.liked || engagement.disliked) {
3933
+ out.push(`
3934
+ ## Engagement`);
3935
+ if (engagement.liked)
3936
+ out.push(`- liked \u2705`);
3937
+ if (engagement.disliked)
3938
+ out.push(`- disliked \u274C`);
3939
+ for (const [k, v] of activeCounts) {
3940
+ out.push(`- ${k}: ${v}`);
3941
+ }
3942
+ }
3943
+ if (shape.truncated) {
3944
+ out.push(`
3945
+ _Truncated_: ${shape.truncation_hint ?? "response trimmed"}_`);
3946
+ }
3947
+ return out.join("\n");
3948
+ }
3124
3949
  var SECTION_PRIORITY = ["profile", "signals", "clues"];
3125
3950
  function splitEmojiSection(key) {
3126
3951
  const m = key.match(/^([^\p{L}\p{N}\s]+)\s+(.+)$/u);
@@ -3155,6 +3980,13 @@ function reshapeWebFetchContent(content) {
3155
3980
  }
3156
3981
  var researchLead = {
3157
3982
  name: "leadbay_research_lead",
3983
+ annotations: {
3984
+ title: "Research a Leadbay lead in depth",
3985
+ readOnlyHint: true,
3986
+ destructiveHint: false,
3987
+ idempotentHint: true,
3988
+ openWorldHint: true
3989
+ },
3158
3990
  description: "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. Scoring has two layers: the basic `score` (firmographic, always present, already decent) and the AI qualification layer (`ai_agent_lead_score` + per-question answers + web_fetch signals). The AI layer is pre-populated for roughly the top 10 of each daily batch, and on-demand (via leadbay_bulk_qualify_leads) for anything below that. Combine both layers when judging a lead. When to use: when picking up a single lead from leadbay_pull_leads to decide whether to act on it. When NOT to use: across many leads at once \u2014 that's leadbay_pull_leads' job. (This composite supersedes the lower-level leadbay_get_lead_profile in agent flow; the granular tool stays available for fine-grained access.)",
3159
3991
  inputSchema: {
3160
3992
  type: "object",
@@ -3167,9 +3999,108 @@ var researchLead = {
3167
3999
  concise: {
3168
4000
  type: "boolean",
3169
4001
  description: "If true, trim signals to hot=true items only (smaller payload). Default false."
4002
+ },
4003
+ response_format: {
4004
+ type: "string",
4005
+ enum: ["json", "markdown"],
4006
+ description: "How the agent wants the result rendered. 'json' (default): the structured payload as text. 'markdown': a compact human-readable rendering (sections + bullets) \u2014 useful for chat-rendering clients (Cursor, Claude Desktop) where the user sees the response directly. structuredContent is emitted in both modes so capable clients still get typed access."
3170
4007
  }
3171
4008
  },
3172
- required: ["leadId"]
4009
+ required: ["leadId"],
4010
+ additionalProperties: false
4011
+ },
4012
+ outputSchema: {
4013
+ type: "object",
4014
+ properties: {
4015
+ qualification: {
4016
+ type: "array",
4017
+ description: "Per-question AI qualification answers, ordered by mission importance. Each entry: question, boost_score (canonical -10|0|10|20), score_scale, response, computed_at. score_0_to_10 is a deprecated alias of boost_score (removed in 0.7.0).",
4018
+ items: {
4019
+ type: "object",
4020
+ properties: {
4021
+ question: { type: "string" },
4022
+ boost_score: { type: ["number", "null"] },
4023
+ score_scale: { type: "string" },
4024
+ score_0_to_10: { type: ["number", "null"] },
4025
+ response: { type: ["string", "null"] },
4026
+ computed_at: { type: ["string", "null"] }
4027
+ }
4028
+ }
4029
+ },
4030
+ signals: {
4031
+ type: "array",
4032
+ description: "Web-research signals reshaped into priority-ordered sections (profile \u2192 signals \u2192 clues \u2192 other). Each entry: section_label, section_emoji, entries[]. With concise:true, only hot=true entries kept. May be auto-trimmed when truncated:true (see below).",
4033
+ items: { type: "object" }
4034
+ },
4035
+ truncated: {
4036
+ type: "boolean",
4037
+ description: "True when the response was auto-trimmed to stay under the ~25k-char budget. Always false when concise:true is passed."
4038
+ },
4039
+ truncation_hint: {
4040
+ type: ["string", "null"],
4041
+ description: "When truncated:true, names the specific argument that would reduce the payload (typically 'concise:true')."
4042
+ },
4043
+ firmographics: {
4044
+ type: "object",
4045
+ description: "Lead profile basics. iter-30: nested additionalProperties:false closes the output-side strictness frontier \u2014 runtime returns must match exactly these keys.",
4046
+ properties: {
4047
+ id: { type: "string" },
4048
+ name: { type: "string" },
4049
+ sector_id: { type: ["number", "string", "null"] },
4050
+ size: { type: ["string", "null"] },
4051
+ location: { type: ["string", "null"] },
4052
+ website: { type: ["string", "null"] },
4053
+ description: { type: ["string", "null"] },
4054
+ short_description: { type: ["string", "null"] },
4055
+ keywords: { type: "array", items: { type: "string" } },
4056
+ tags: { type: "array", items: { type: "string" } },
4057
+ score: { type: ["number", "null"] },
4058
+ ai_agent_lead_score: { type: ["number", "null"] },
4059
+ social_presence: { type: ["object", "string", "null"] },
4060
+ social_urls: { type: ["object", "array", "null"] },
4061
+ registry_ids: { type: ["object", "array", "null"] }
4062
+ },
4063
+ additionalProperties: false
4064
+ },
4065
+ contacts: {
4066
+ type: "object",
4067
+ description: "Two-tier contact set: `enriched` (paid contacts known on this lens for this lead) and `org` (org-level contacts visible beyond the lens).",
4068
+ properties: {
4069
+ enriched: { type: "array", items: { type: "object" } },
4070
+ org: { type: "array", items: { type: "object" } }
4071
+ },
4072
+ additionalProperties: false
4073
+ },
4074
+ engagement: {
4075
+ type: "object",
4076
+ description: "What humans/prior agent runs already did: liked/disliked flags, recommended_contact, counts (notes/epilogue/prospecting), and the most-recent items (recent_notes, recent_epilogue, recent_prospecting). Counts > 0 trigger conditional fan-out for the recent_* fields.",
4077
+ properties: {
4078
+ liked: { type: "boolean" },
4079
+ disliked: { type: "boolean" },
4080
+ new: { type: "boolean" },
4081
+ recommended_contact_title: { type: ["string", "null"] },
4082
+ recommended_contact: { type: ["object", "null"] },
4083
+ notes_count: { type: "number" },
4084
+ epilogue_actions_count: { type: "number" },
4085
+ prospecting_actions_count: { type: "number" },
4086
+ recent_notes: { type: "array", items: { type: "object" } },
4087
+ recent_epilogue: { type: "array", items: { type: "object" } },
4088
+ recent_prospecting: { type: "array", items: { type: "object" } }
4089
+ },
4090
+ additionalProperties: false
4091
+ },
4092
+ _meta: {
4093
+ type: "object",
4094
+ description: "Operator context: region (us/fr/custom), lens_id (the lens used for the lead-by-id fetch), web_fetch_in_progress (true if the backend is still hydrating signals).",
4095
+ properties: {
4096
+ region: { type: "string" },
4097
+ lens_id: { type: "number" },
4098
+ web_fetch_in_progress: { type: "boolean" }
4099
+ },
4100
+ additionalProperties: false
4101
+ }
4102
+ },
4103
+ required: ["qualification", "signals", "firmographics", "contacts", "engagement"]
3173
4104
  },
3174
4105
  execute: async (client, params, ctx) => {
3175
4106
  const lensId = params.lensId ?? await client.resolveDefaultLens();
@@ -3210,16 +4141,50 @@ var researchLead = {
3210
4141
  }
3211
4142
  const paidContacts = contactsR.status === "fulfilled" ? contactsR.value : [];
3212
4143
  const orgContacts = valOrNull(orgContactsR) ?? [];
4144
+ const TRUNCATE_CHAR_BUDGET = 25e3;
4145
+ let truncated = false;
4146
+ let truncationHint = null;
4147
+ const probeSize = (obj) => {
4148
+ try {
4149
+ return JSON.stringify(obj).length;
4150
+ } catch {
4151
+ return 0;
4152
+ }
4153
+ };
4154
+ let signalsForReturn = signals;
4155
+ if (!params.concise) {
4156
+ const signalsSize = probeSize(signals);
4157
+ if (signalsSize > TRUNCATE_CHAR_BUDGET) {
4158
+ truncated = true;
4159
+ truncationHint = "Response truncated to fit context. Pass concise:true to filter to hot signals only.";
4160
+ signalsForReturn = signals.map((s) => ({
4161
+ ...s,
4162
+ entries: s.entries.slice(0, 2)
4163
+ }));
4164
+ }
4165
+ }
3213
4166
  return {
3214
4167
  // 1) qualification — single most important block for "is this lead worth pursuing"
4168
+ // boost_score is the canonical field (per AiAgentResponse.score). The valid
4169
+ // set is the discrete -10/0/10/20 boost (see types.ts comment), NOT a 0-10
4170
+ // average — the eval doc flagged the old `score_0_to_10` field name as
4171
+ // misleading. We now ship `boost_score` as canonical alongside an explicit
4172
+ // `score_scale` annotation; `score_0_to_10` is kept as a deprecated alias
4173
+ // for one minor version (0.6.x) and removed in 0.7.0. See MIGRATION.md.
3215
4174
  qualification: qualR.status === "fulfilled" ? qualR.value.map((r) => ({
3216
4175
  question: r.question,
4176
+ boost_score: r.score,
4177
+ score_scale: "-10|0|10|20",
4178
+ // Deprecated alias — same value as boost_score. Will be removed
4179
+ // in 0.7.0; consumers should switch to boost_score.
3217
4180
  score_0_to_10: r.score,
3218
4181
  response: r.response,
3219
4182
  computed_at: r.computed_at
3220
4183
  })) : [],
3221
- // 2) signals — knowledge-base food
3222
- signals,
4184
+ // 2) signals — knowledge-base food (may be trimmed when truncated:true)
4185
+ signals: signalsForReturn,
4186
+ truncated,
4187
+ truncation_hint: truncationHint,
3223
4188
  // 3) firmographics
3224
4189
  firmographics: {
3225
4190
  id: lead.id,
@@ -3281,10 +4246,32 @@ var researchLead = {
3281
4246
  };
3282
4247
  }
3283
4248
  };
4249
+ var _innerExecute = researchLead.execute;
4250
+ researchLead.execute = async (client, params, ctx) => {
4251
+ const result = await _innerExecute(client, params, ctx);
4252
+ if (params.response_format !== "markdown")
4253
+ return result;
4254
+ if (result && typeof result === "object" && result.error === true) {
4255
+ return result;
4256
+ }
4257
+ const envelope = {
4258
+ __markdown_envelope: true,
4259
+ markdown: renderResearchLeadMarkdown(result),
4260
+ structured: result
4261
+ };
4262
+ return envelope;
4263
+ };
3284
4264
 
3285
4265
  // ../core/dist/composite/recall-ordered-titles.js
3286
4266
  var recallOrderedTitles = {
3287
4267
  name: "leadbay_recall_ordered_titles",
4268
+ annotations: {
4269
+ title: "Recall titles previously enriched",
4270
+ readOnlyHint: true,
4271
+ destructiveHint: false,
4272
+ idempotentHint: true,
4273
+ openWorldHint: true
4274
+ },
3288
4275
  description: "Show job titles the org has previously enriched, so the agent can repeat the same titles for new leads (or skip already-saturated ones). Two implementation paths: (1) PREFERRED: a selection-scoped preview call that reads previously_enriched_titles from the backend (newer prod field). (2) FALLBACK: live aggregation across each lead's enriched contacts. The composite picks transparently. When to use: before leadbay_enrich_titles, to plan which titles to order. When NOT to use: when you already know the exact titles you want to enrich.",
3289
4276
  inputSchema: {
3290
4277
  type: "object",
@@ -3298,7 +4285,32 @@ var recallOrderedTitles = {
3298
4285
  type: "number",
3299
4286
  description: "Override the auto-resolved last-active lens when omitting leadIds (escape hatch)"
3300
4287
  }
3301
- }
4288
+ },
4289
+ additionalProperties: false
4290
+ },
4291
+ outputSchema: {
4292
+ type: "object",
4293
+ properties: {
4294
+ source: {
4295
+ type: "string",
4296
+ description: "'preview_field' (preferred backend path) or 'live_aggregate' (fallback aggregation across each lead's contacts)."
4297
+ },
4298
+ titles: {
4299
+ type: "array",
4300
+ description: "Titles previously enriched. preview_field path: [{title}]. live_aggregate path: [{title, leads_with_enriched, total_enriched_contacts, leads_still_having_unenriched}].",
4301
+ items: { type: "object" }
4302
+ },
4303
+ available_in_selection: {
4304
+ type: "array",
4305
+ description: "Backend-suggested title candidates available for ordering (preview_field path only).",
4306
+ items: { type: "object" }
4307
+ },
4308
+ note: {
4309
+ type: "string",
4310
+ description: "Operator note explaining the chosen path (e.g., empty input set, fallback in use)."
4311
+ }
4312
+ },
4313
+ required: ["source", "titles"]
3302
4314
  },
3303
4315
  execute: async (client, params, ctx) => {
3304
4316
  let leadIds = params.leadIds;
@@ -3374,8 +4386,58 @@ var recallOrderedTitles = {
3374
4386
  // ../core/dist/composite/account-status.js
3375
4387
  var accountStatus = {
3376
4388
  name: "leadbay_account_status",
4389
+ annotations: {
4390
+ title: "Show Leadbay account + quota state",
4391
+ readOnlyHint: true,
4392
+ destructiveHint: false,
4393
+ idempotentHint: true,
4394
+ openWorldHint: true
4395
+ },
3377
4396
  description: "Show the user's account state \u2014 admin rights, language, last-active lens, current quota usage across daily/weekly/monthly windows for llm_completion / ai_rescore / web_fetch resources, and whether the org's intelligence is mid-regeneration. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption. When to use: at the start of a session to know what the agent can/can't do, or after a 429 to explain to the user which resource window was exhausted and when it resets. When NOT to use: as a pre-flight gate before bulk ops \u2014 operations themselves return 429; this tool is for context, not gating.",
3378
- inputSchema: { type: "object", properties: {} },
4397
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
4398
+ outputSchema: {
4399
+ type: "object",
4400
+ properties: {
4401
+ user: {
4402
+ type: "object",
4403
+ description: "Identity & roles for the current bearer-token holder.",
4404
+ properties: {
4405
+ email: { type: ["string", "null"] },
4406
+ name: { type: ["string", "null"] },
4407
+ admin: { type: "boolean" },
4408
+ manager: { type: "boolean" },
4409
+ language: { type: "string" }
4410
+ }
4411
+ },
4412
+ organization: {
4413
+ type: "object",
4414
+ description: "Org-level state and feature flags.",
4415
+ properties: {
4416
+ id: { type: "string" },
4417
+ name: { type: "string" },
4418
+ ai_agent_enabled: { type: "boolean" },
4419
+ computing_intelligence: {
4420
+ type: "boolean",
4421
+ description: "True if Leadbay is mid-regenerating intelligence after a refine_prompt; new leads will reflect it shortly."
4422
+ },
4423
+ plan: { type: ["string", "null"] }
4424
+ }
4425
+ },
4426
+ last_requested_lens: {
4427
+ type: ["number", "null"],
4428
+ description: "Most recent lens id the user pulled leads from."
4429
+ },
4430
+ quota: {
4431
+ type: ["object", "null"],
4432
+ description: "Per-resource quota state (llm_completion, ai_rescore, web_fetch) across daily/weekly/monthly windows. Null if /quota_status failed (logged in stderr)."
4433
+ },
4434
+ _meta: {
4435
+ type: "object",
4436
+ properties: { region: { type: "string" } }
4437
+ }
4438
+ },
4439
+ required: ["user", "organization"]
4440
+ },
3379
4441
  execute: async (client, _params, ctx) => {
3380
4442
  const me = await client.resolveMe();
3381
4443
  let quota = null;
@@ -3419,6 +4481,15 @@ var DEFAULT_PER_LEAD_BUDGET_MS = 9e4;
3419
4481
  var DEFAULT_TOTAL_BUDGET_MS2 = 5 * 6e4;
3420
4482
  var bulkQualifyLeads = {
3421
4483
  name: "leadbay_bulk_qualify_leads",
4484
+ annotations: {
4485
+ title: "Bulk-qualify next N leads",
4486
+ readOnlyHint: false,
4487
+ destructiveHint: true,
4488
+ // Same set of leads + same options ⇒ same backend job (idempotency
4489
+ // hash); already-qualified leads are silent no-ops. Re-call is safe.
4490
+ idempotentHint: true,
4491
+ openWorldHint: true
4492
+ },
3422
4493
  description: "Pick the next N unqualified leads in the active lens and qualify them (run AI rescore + web fetch), polling until the answers are populated or a budget is exhausted. Already-qualified leads (those with a non-null ai_agent_lead_score) are silently no-ops on the backend, so this composite paginates past them to find fresh candidates. On 429 mid-fanout, stops launching but keeps polling already-launched leads. Context: Leadbay auto-qualifies roughly the top 10 of each daily batch. Leads below the top ~10 are NOT worse \u2014 the system is saving resources. This tool is how the agent spends more resources to go deeper on promising-looking leads the user hasn't had time to surface yet. When to use: when the user wants more qualified leads than what's currently shown, or when a lead looks promising in leadbay_pull_leads but has an empty qualification_summary. When NOT to use: to qualify a single specific lead \u2014 that's leadbay_qualify_lead (granular, advanced).",
3423
4494
  inputSchema: {
3424
4495
  type: "object",
@@ -3444,7 +4515,48 @@ var bulkQualifyLeads = {
3444
4515
  type: "number",
3445
4516
  description: `Total polling budget in ms (default ${DEFAULT_TOTAL_BUDGET_MS2})`
3446
4517
  }
3447
- }
4518
+ },
4519
+ additionalProperties: false
4520
+ },
4521
+ outputSchema: {
4522
+ type: "object",
4523
+ properties: {
4524
+ qualified: {
4525
+ type: "array",
4526
+ description: "Leads whose qualification finished within budget. Each entry: lead_id, qualification_summary{answered,total,avg_qualification_boost}, signals_count.",
4527
+ items: { type: "object" }
4528
+ },
4529
+ still_running: {
4530
+ type: "array",
4531
+ description: "Leads launched but whose qualification did not complete within budget. Re-poll via leadbay_qualify_status with the bulk_id (when present).",
4532
+ items: { type: "object" }
4533
+ },
4534
+ failed: {
4535
+ type: "array",
4536
+ description: "Leads whose web_fetch launch failed (per-lead error).",
4537
+ items: { type: "object" }
4538
+ },
4539
+ quota_exceeded: {
4540
+ type: "boolean",
4541
+ description: "True if 429 was hit mid-fanout. Already-launched leads keep polling; further launches stopped."
4542
+ },
4543
+ exhausted: {
4544
+ type: "boolean",
4545
+ description: "True if the lens's wishlist had no more unqualified leads to qualify."
4546
+ },
4547
+ total_unqualified_found: { type: "number" },
4548
+ message: { type: "string", description: "Human-readable summary; absent on the happy path." },
4549
+ lens_id: {
4550
+ type: "number",
4551
+ description: "The lens id the qualification ran against. Present on every successful return."
4552
+ },
4553
+ _meta: {
4554
+ type: "object",
4555
+ description: "Operator context: region.",
4556
+ properties: { region: { type: "string" } }
4557
+ }
4558
+ },
4559
+ required: ["qualified", "still_running", "failed", "quota_exceeded"]
3448
4560
  },
3449
4561
  execute: async (client, params, ctx) => {
3450
4562
  const wantCount = Math.min(params.count ?? DEFAULT_COUNT, MAX_COUNT);
@@ -3516,11 +4628,33 @@ var bulkQualifyLeads = {
3516
4628
  }
3517
4629
  }
3518
4630
  }
4631
+ let progressDone = 0;
4632
+ const progressTotal = launched.length;
4633
+ if (progressTotal > 0) {
4634
+ ctx?.progress?.({
4635
+ progress: 0,
4636
+ total: progressTotal,
4637
+ message: `Starting qualification for ${progressTotal} lead${progressTotal === 1 ? "" : "s"}`
4638
+ });
4639
+ }
4640
+ const sleepWithSignal = (ms) => new Promise((resolve) => {
4641
+ if (ctx?.signal?.aborted) {
4642
+ resolve();
4643
+ return;
4644
+ }
4645
+ const t = setTimeout(resolve, ms);
4646
+ ctx?.signal?.addEventListener("abort", () => {
4647
+ clearTimeout(t);
4648
+ resolve();
4649
+ }, { once: true });
4650
+ });
3519
4651
  const results = await Promise.all(launched.map(async (leadId) => {
3520
4652
  const leadDeadline = Math.min(Date.now() + perLeadBudget, totalDeadline);
3521
4653
  let lastQual = null;
3522
4654
  let lastWf = null;
3523
4655
  while (Date.now() < leadDeadline) {
4656
+ if (ctx?.signal?.aborted)
4657
+ break;
3524
4658
  try {
3525
4659
  const [wfR, qualR] = await Promise.allSettled([
3526
4660
  client.request("GET", `/leads/${leadId}/web_fetch`),
@@ -3531,11 +4665,20 @@ var bulkQualifyLeads = {
3531
4665
  if (qualR.status === "fulfilled")
3532
4666
  lastQual = qualR.value;
3533
4667
  const done = lastWf !== null && lastWf.in_progress !== true && Array.isArray(lastQual) && lastQual.length > 0 && lastQual.every((r) => r.score != null);
3534
- if (done)
4668
+ if (done) {
4669
+ progressDone += 1;
4670
+ ctx?.progress?.({
4671
+ progress: progressDone,
4672
+ total: progressTotal,
4673
+ message: `Qualified lead ${leadId} (${progressDone}/${progressTotal})`
4674
+ });
3535
4675
  break;
4676
+ }
3536
4677
  } catch {
3537
4678
  }
3538
- await new Promise((r) => setTimeout(r, 5e3));
4679
+ if (ctx?.signal?.aborted)
4680
+ break;
4681
+ await sleepWithSignal(5e3);
3539
4682
  }
3540
4683
  const stillRunning = lastWf?.in_progress === true || !lastQual || lastQual.length === 0 || lastQual.some((r) => r.score == null);
3541
4684
  const responses = lastQual ?? [];
@@ -3646,6 +4789,16 @@ function buildFingerprintInput(mappings) {
3646
4789
  }
3647
4790
  var importAndQualify = {
3648
4791
  name: "leadbay_import_and_qualify",
4792
+ annotations: {
4793
+ title: "Import + qualify leads",
4794
+ readOnlyHint: false,
4795
+ destructiveHint: true,
4796
+ // Composite of import (idempotent against domain hash) + qualify (which
4797
+ // is silent no-op for already-qualified leads). bulk-store + import
4798
+ // hashes return same handles on retry.
4799
+ idempotentHint: true,
4800
+ openWorldHint: true
4801
+ },
3649
4802
  description: `Composite: import a list of leads (CSV-shaped records OR a list of domains), then trigger Leadbay's AI qualification (web research + per-question scoring) on every imported leadId, and return both the import outcome and the per-lead qualification answers \u2014 in one call. Honours a total wall-clock budget; when the budget is exhausted before all leads finish qualifying, returns a \`qualify_id\` UUID handle that survives MCP restart and can be passed to leadbay_qualify_status to retrieve the rest of the answers later.
3650
4803
 
3651
4804
  Inputs:
@@ -3690,7 +4843,11 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
3690
4843
  description: "Optional display name override; defaults to the domain."
3691
4844
  }
3692
4845
  },
3693
- required: ["domain"]
4846
+ required: ["domain"],
4847
+ // Closed shape — extra keys silently no-op, so reject explicitly.
4848
+ // Parallel surface to import_leads.domains[] (iter 13). Per second-
4849
+ // opinion #2 finding #3.
4850
+ additionalProperties: false
3694
4851
  }
3695
4852
  },
3696
4853
  records: {
@@ -3712,7 +4869,11 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
3712
4869
  },
3713
4870
  statuses: { type: "object", description: "Optional status string mapping." },
3714
4871
  default_status: { type: ["string", "null"], description: "Optional default status." }
3715
- }
4872
+ },
4873
+ // mappings has a closed shape (fields/custom_fields/statuses/default_status).
4874
+ // Inner objects (fields, custom_fields, statuses) keep open shapes
4875
+ // because their keys are user-defined CSV column names.
4876
+ additionalProperties: false
3716
4877
  },
3717
4878
  per_lead_budget_ms: {
3718
4879
  type: "number",
@@ -3737,7 +4898,105 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
3737
4898
  type: "boolean",
3738
4899
  description: "When true (default), skips web_fetch launch on leads whose ai_agent_lead_score is already non-null. Saves quota. Set false to force fresh re-qualification."
3739
4900
  }
3740
- }
4901
+ },
4902
+ additionalProperties: false
4903
+ },
4904
+ outputSchema: {
4905
+ type: "object",
4906
+ description: "Two return shapes: kind:'preview' (when dry_run='preview') with mapping hints; kind:'result' (default) with imported + qualified leads + qualify_id handle.",
4907
+ properties: {
4908
+ kind: {
4909
+ type: "string",
4910
+ description: "'result' (full flow) or 'preview' (dry_run='preview' mapping diagnostics)."
4911
+ },
4912
+ // preview-shape keys
4913
+ mapping_hints: {
4914
+ type: "array",
4915
+ description: "Per-column AI-confidence suggestions (preview shape).",
4916
+ items: { type: "object" }
4917
+ },
4918
+ custom_field_candidates: {
4919
+ type: "array",
4920
+ description: "Org custom fields that match unmapped columns (preview shape).",
4921
+ items: { type: "object" }
4922
+ },
4923
+ sample_rows: {
4924
+ type: "array",
4925
+ description: "First few rows of the preprocessed sample (preview shape).",
4926
+ items: { type: "object" }
4927
+ },
4928
+ notes: {
4929
+ type: "array",
4930
+ description: "Operator notes (e.g., catalog fetch errors).",
4931
+ items: { type: "string" }
4932
+ },
4933
+ import_id: {
4934
+ type: "string",
4935
+ description: "Backend file-import handle (preview shape)."
4936
+ },
4937
+ // result-shape keys
4938
+ dry_run: { type: "boolean", description: "True when dry_run:true was passed." },
4939
+ chosen_budgets: {
4940
+ type: "object",
4941
+ description: "Adaptive budgets the composite selected (when caller didn't override): {per_lead_budget_ms, total_budget_ms, per_phase_budget_ms, wall_clock_estimate_ms, strategy}."
4942
+ },
4943
+ qualify_id: {
4944
+ type: ["string", "null"],
4945
+ description: "UUIDv4 handle for polling via leadbay_qualify_status. Null when no leads were qualified."
4946
+ },
4947
+ import_ids: {
4948
+ type: "array",
4949
+ description: "Backend file-import handles (one per chunk).",
4950
+ items: { type: "string" }
4951
+ },
4952
+ imported: {
4953
+ type: "array",
4954
+ description: "Leads that landed in CRM. Each: {leadId, domain?, name, rowId?}.",
4955
+ items: { type: "object" }
4956
+ },
4957
+ not_imported: {
4958
+ type: "array",
4959
+ description: "Inputs that didn't land. Each has a `reason` plus the input echo.",
4960
+ items: { type: "object" }
4961
+ },
4962
+ qualified: {
4963
+ type: "array",
4964
+ description: "Leads whose qualification settled within budgets.",
4965
+ items: { type: "object" }
4966
+ },
4967
+ still_running: {
4968
+ type: "array",
4969
+ description: "Leads still being qualified at deadline; agent calls leadbay_qualify_status with qualify_id.",
4970
+ items: { type: "object" }
4971
+ },
4972
+ failed: {
4973
+ type: "array",
4974
+ description: "Per-lead errors observed during qualification.",
4975
+ items: { type: "object" }
4976
+ },
4977
+ quota_exceeded: { type: "boolean" },
4978
+ skipped_already_qualified: {
4979
+ type: "array",
4980
+ description: "Lead ids skipped because ai_agent_lead_score was already non-null (skip_already_qualified=true).",
4981
+ items: { type: "string" }
4982
+ },
4983
+ not_in_lens: {
4984
+ type: "array",
4985
+ description: "Lead ids that aren't members of the active lens \u2014 backend won't qualify them.",
4986
+ items: { type: "string" }
4987
+ },
4988
+ reused: {
4989
+ type: "boolean",
4990
+ description: "True when an identical qualify_id was launched within the idempotency window."
4991
+ },
4992
+ seconds_since_original: { type: "number" },
4993
+ cancelled: { type: "boolean", description: "True when ctx.signal aborted mid-flight." },
4994
+ budget_exhausted: { type: "boolean", description: "True when total_budget_ms hit before all leads finished." },
4995
+ quota_blocked: { type: "boolean", description: "True when quota was exhausted before launching all leads." },
4996
+ region: { type: "string" },
4997
+ _meta: { type: "object" }
4998
+ },
4999
+ required: ["kind", "region", "_meta"]
3741
5000
  },
3742
5001
  execute: async (client, params, ctx) => {
3743
5002
  const signal = ctx?.signal;
@@ -3756,6 +5015,11 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
3756
5015
  if (!ctx?.bulkTracker) {
3757
5016
  throw client.makeError("BULK_TRACKER_UNAVAILABLE", "No BulkTracker configured on this MCP instance", "leadbay_import_and_qualify needs a BulkTracker (qualify_id persistence). Upgrade to @leadbay/mcp \u22650.5.0 or set LEADBAY_BULK_STORE_ALLOW_MEMORY=1.", "");
3758
5017
  }
5018
+ ctx?.progress?.({
5019
+ progress: 1,
5020
+ total: 3,
5021
+ message: "Importing leads (phase 1/3 \u2014 preprocess + commit)"
5022
+ });
3759
5023
  const importResult = await importLeads.execute(client, {
3760
5024
  domains: params.domains,
3761
5025
  records: params.records,
@@ -3825,6 +5089,11 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
3825
5089
  }
3826
5090
  };
3827
5091
  }
5092
+ ctx?.progress?.({
5093
+ progress: 2,
5094
+ total: 3,
5095
+ message: "Import committed; preparing qualification (phase 2/3)"
5096
+ });
3828
5097
  const lensId = params.lensId ?? await client.resolveDefaultLens();
3829
5098
  const leadIdsFromImported = imported.map((l) => l.leadId);
3830
5099
  const leadIdSet = new Set(leadIdsFromImported);
@@ -3877,6 +5146,11 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
3877
5146
  } catch (err) {
3878
5147
  ctx?.logger?.warn?.(`qualify: question order unavailable (${err?.code ?? err?.message ?? "unknown"}) \u2014 falling back to alphabetical`);
3879
5148
  }
5149
+ ctx?.progress?.({
5150
+ progress: 3,
5151
+ total: 3,
5152
+ message: `Qualifying ${leadIds.length} lead${leadIds.length === 1 ? "" : "s"} (phase 3/3)`
5153
+ });
3880
5154
  const fanOut = await fanOutWebFetchAndPoll(client, leadIds, {
3881
5155
  perLeadBudgetMs: perLeadBudget,
3882
5156
  totalDeadlineMs: totalDeadline,
@@ -3886,6 +5160,13 @@ Requires: LEADBAY_MCP_WRITE=1 (MCP) or exposeWrite=true (OpenClaw); admin role;
3886
5160
  skipAlreadyQualifiedLaunch: skipAlreadyQualified,
3887
5161
  ...questionOrder ? { questionOrder } : {}
3888
5162
  });
5163
+ if (fanOut.cancelled) {
5164
+ try {
5165
+ await ctx.bulkTracker.markCancelled(reservation.record.bulk_id);
5166
+ } catch (err) {
5167
+ ctx?.logger?.warn?.(`import_and_qualify: tracker.markCancelled failed: ${err?.message ?? err}`);
5168
+ }
5169
+ }
3889
5170
  const qualified = fanOut.results.filter((r) => !r._stillRunning).map(({ _stillRunning, ...rest }) => rest);
3890
5171
  const notInLensSet = new Set(fanOut.not_in_lens);
3891
5172
  const stillRunningIds = new Set([
@@ -3963,6 +5244,11 @@ async function runPreview(client, params, ctx, perPhaseBudget, _totalBudget) {
3963
5244
  const upload = await client.requestRawBinary("POST", `/imports?file_name=${encodeURIComponent(fileName)}`, "text/csv", csv);
3964
5245
  const importId = upload.id;
3965
5246
  const signal = ctx?.signal;
5247
+ ctx?.progress?.({
5248
+ progress: 1,
5249
+ total: 3,
5250
+ message: "Preprocessing import (phase 1/3)"
5251
+ });
3966
5252
  const deadline = Date.now() + perPhaseBudget;
3967
5253
  let fileImport = null;
3968
5254
  while (Date.now() < deadline) {
@@ -3972,6 +5258,11 @@ async function runPreview(client, params, ctx, perPhaseBudget, _totalBudget) {
3972
5258
  const r = await client.request("GET", `/imports/${importId}`);
3973
5259
  if (r.pre_processing?.finished) {
3974
5260
  fileImport = r;
5261
+ ctx?.progress?.({
5262
+ progress: 2,
5263
+ total: 3,
5264
+ message: "Preprocess complete; committing import (phase 2/3)"
5265
+ });
3975
5266
  break;
3976
5267
  }
3977
5268
  await new Promise((res) => {
@@ -4411,6 +5702,18 @@ var LocalBulkStore = class {
4411
5702
  this.logger?.info?.(`bulk.launch_failed bulk_id=${bulk_id}`);
4412
5703
  });
4413
5704
  }
5705
+ async markCancelled(bulk_id) {
5706
+ return this.mutex.run(async () => {
5707
+ const all = this.prune(await this.readAll());
5708
+ const idx = all.findIndex((r) => r.bulk_id === bulk_id);
5709
+ if (idx < 0) {
5710
+ return;
5711
+ }
5712
+ all[idx] = { ...all[idx], status: "cancelled" };
5713
+ await this.writeAll(all);
5714
+ this.logger?.info?.(`bulk.cancelled bulk_id=${bulk_id}`);
5715
+ });
5716
+ }
4414
5717
  async get(bulk_id) {
4415
5718
  return this.mutex.run(async () => {
4416
5719
  const all = this.prune(await this.readAll());
@@ -4470,6 +5773,13 @@ async function createDefaultBulkStore(opts = {}) {
4470
5773
  // ../core/dist/composite/qualify-status.js
4471
5774
  var qualifyStatus = {
4472
5775
  name: "leadbay_qualify_status",
5776
+ annotations: {
5777
+ title: "Poll import-and-qualify status",
5778
+ readOnlyHint: true,
5779
+ destructiveHint: false,
5780
+ idempotentHint: true,
5781
+ openWorldHint: true
5782
+ },
4473
5783
  description: "Retrieve the current state of an import_and_qualify launch by `qualify_id`. Returns the same `qualified[]` / `still_running[]` shape as the original composite, refreshed against the backend at call time. The handle is persisted to ~/.leadbay/bulks.json with a 30-day TTL and survives MCP restart.\n\nWhen to use: after leadbay_import_and_qualify returned a qualify_id with non-empty `still_running[]`, call this tool a few minutes later (or hours) to retrieve the now-completed qualifications without re-running the import or re-spending qualify quota.\nWhen NOT to use: as a substitute for leadbay_research_lead \u2014 that's a deeper per-lead profile and includes contacts. This tool is purely the qualification answers + signals_count.",
4474
5784
  inputSchema: {
4475
5785
  type: "object",
@@ -4479,7 +5789,70 @@ var qualifyStatus = {
4479
5789
  description: "UUIDv4 returned by leadbay_import_and_qualify when at least one lead was still running."
4480
5790
  }
4481
5791
  },
4482
- required: ["qualify_id"]
5792
+ required: ["qualify_id"],
5793
+ additionalProperties: false
5794
+ },
5795
+ outputSchema: {
5796
+ type: "object",
5797
+ properties: {
5798
+ qualify_id: { type: "string", description: "Echoed UUIDv4 handle." },
5799
+ launched_at: { type: "string", description: "ISO timestamp of original launch." },
5800
+ status: { type: "string", description: "'launched' on success (other states surface as error envelopes)." },
5801
+ import_ids: {
5802
+ type: "array",
5803
+ description: "Underlying file-import handle ids (one per chunk).",
5804
+ items: { type: "string" }
5805
+ },
5806
+ lens_id: { type: "number", description: "Lens id the qualification ran against." },
5807
+ lead_ids: {
5808
+ type: "array",
5809
+ description: "Lead UUIDs covered by this qualify_id (echoed from launch).",
5810
+ items: { type: "string" }
5811
+ },
5812
+ qualified: {
5813
+ type: "array",
5814
+ description: "Leads whose qualification has settled. Each entry: {lead_id, qualification_summary, signals_count, ...}.",
5815
+ items: { type: "object" }
5816
+ },
5817
+ still_running: {
5818
+ type: "array",
5819
+ description: "Leads still being qualified at refresh time.",
5820
+ items: { type: "object" }
5821
+ },
5822
+ failed: {
5823
+ type: "array",
5824
+ description: "Per-lead errors observed at refresh (e.g., 404 on /web_fetch + /ai_agent_responses).",
5825
+ items: { type: "object" }
5826
+ },
5827
+ not_in_lens: {
5828
+ type: "array",
5829
+ description: "Lead ids that exist in the org but aren't members of the active lens \u2014 backend won't qualify them; agent should stop polling.",
5830
+ items: { type: "string" }
5831
+ },
5832
+ per_lead_budget_ms: {
5833
+ type: "number",
5834
+ description: "Caller-supplied per-lead timeout (informational only at status time)."
5835
+ },
5836
+ total_budget_ms: {
5837
+ type: "number",
5838
+ description: "Caller-supplied total timeout (informational only at status time)."
5839
+ },
5840
+ region: { type: "string" },
5841
+ _meta: { type: "object" }
5842
+ },
5843
+ required: [
5844
+ "qualify_id",
5845
+ "status",
5846
+ "import_ids",
5847
+ "lens_id",
5848
+ "lead_ids",
5849
+ "qualified",
5850
+ "still_running",
5851
+ "failed",
5852
+ "not_in_lens",
5853
+ "region",
5854
+ "_meta"
5855
+ ]
4483
5856
  },
4484
5857
  execute: async (client, params, ctx) => {
4485
5858
  if (!isValidBulkId(params.qualify_id)) {
@@ -4502,18 +5875,36 @@ var qualifyStatus = {
4502
5875
  if (record.status === "failed") {
4503
5876
  throw client.makeError("BULK_LAUNCH_FAILED", "The original import_and_qualify launch failed; no qualifications were ordered", "Call leadbay_import_and_qualify again \u2014 the failed record won't block a fresh launch.", "");
4504
5877
  }
5878
+ if (record.status === "cancelled") {
5879
+ throw client.makeError("BULK_CANCELLED", "The qualify run was cancelled (ctx.signal aborted by the client mid-flight); no further qualifications are in flight", "Call leadbay_import_and_qualify again with the same input to relaunch \u2014 the cancelled record won't block a fresh launch.", "");
5880
+ }
5881
+ ctx?.progress?.({
5882
+ progress: 1,
5883
+ total: 3,
5884
+ message: "Loading qualification questions\u2026"
5885
+ });
4505
5886
  let questionOrder = void 0;
4506
5887
  try {
4507
5888
  const taste = await client.resolveTasteProfile();
4508
5889
  questionOrder = buildQuestionOrder(taste.qualificationQuestions ?? []);
4509
5890
  } catch {
4510
5891
  }
5892
+ ctx?.progress?.({
5893
+ progress: 2,
5894
+ total: 3,
5895
+ message: `Checking lens membership for ${record.lead_ids.length} lead${record.lead_ids.length === 1 ? "" : "s"}\u2026`
5896
+ });
4511
5897
  let notInLensSet = /* @__PURE__ */ new Set();
4512
5898
  try {
4513
5899
  const pre = await prequalifiedLeads(client, record.lead_ids, record.lens_id, ctx);
4514
5900
  notInLensSet = pre.not_in_lens;
4515
5901
  } catch {
4516
5902
  }
5903
+ ctx?.progress?.({
5904
+ progress: 3,
5905
+ total: 3,
5906
+ message: `Refreshing qualification state for ${record.lead_ids.length} lead${record.lead_ids.length === 1 ? "" : "s"}\u2026`
5907
+ });
4517
5908
  const fresh = await refreshLeadStates(client, record.lead_ids, questionOrder);
4518
5909
  const failed = [];
4519
5910
  const qualified = [];
@@ -4564,6 +5955,18 @@ var qualifyStatus = {
4564
5955
  var DEFAULT_CANDIDATE_COUNT = 25;
4565
5956
  var enrichTitles = {
4566
5957
  name: "leadbay_enrich_titles",
5958
+ annotations: {
5959
+ title: "Enrich contact titles across leads",
5960
+ readOnlyHint: false,
5961
+ // Mode A (no titles): non-destructive preview returning candidates.
5962
+ // Mode B (with titles): launches enrichment job. Net classification is
5963
+ // destructive because the dominant flow mutates state.
5964
+ destructiveHint: true,
5965
+ // Idempotent against the same selection + titles set (same hash → same
5966
+ // bulk_id; backend silently no-ops on already-enriched contacts).
5967
+ idempotentHint: true,
5968
+ openWorldHint: true
5969
+ },
4567
5970
  description: "Order contact enrichments by job title across many leads. Contacts are NOT returned by default with a lead (Leadbay keeps enrichment out-of-band to control cost); the agent requests them on demand via this tool when it's ready to actually reach out. Two modes: (A) NO titles param \u2014 returns the available titles + Leadbay's title_suggestions + auto_included_titles + a count of enrichable contacts, so the agent can ask the user which titles to enrich. (B) titles given \u2014 calls preview, then launches if there's anything enrichable. On 429 returns {status:'quota_exceeded'} cleanly. Selection lifecycle is wrapped in a try/finally so the user's selection is left clean even on error. When to use: as the agent's go-to enrichment entry point, immediately before proposing outreach. When NOT to use: to enrich a single contact \u2014 that's leadbay_enrich_contacts (granular). Speculatively, before the user has committed to outreaching \u2014 enrichment spends credits.",
4568
5971
  inputSchema: {
4569
5972
  type: "object",
@@ -4592,6 +5995,100 @@ var enrichTitles = {
4592
5995
  type: "boolean",
4593
5996
  description: "If true, don't launch \u2014 only preview."
4594
5997
  }
5998
+ },
5999
+ additionalProperties: false
6000
+ },
6001
+ outputSchema: {
6002
+ type: "object",
6003
+ description: "Branchy return shape; the `mode` (or `status`) field tells the agent which branch it got. Modes: 'discover' (no titles passed), 'preview_only' (no enrichable contacts), 'dry_run', 'already_launched' (idempotent reuse), 'launched_tracker_pending' (rare, soft-fail), 'launched' (happy path). Status: 'quota_exceeded' (429).",
6004
+ properties: {
6005
+ mode: {
6006
+ type: "string",
6007
+ description: "'discover' | 'preview_only' | 'dry_run' | 'already_launched' | 'launched_tracker_pending' | 'launched'."
6008
+ },
6009
+ status: {
6010
+ type: "string",
6011
+ description: "'quota_exceeded' on 429. Otherwise mode is set instead."
6012
+ },
6013
+ available_titles: {
6014
+ type: "array",
6015
+ description: "Titles available across the selection (discover/preview_only modes).",
6016
+ items: { type: "string" }
6017
+ },
6018
+ recommendations: {
6019
+ type: "array",
6020
+ description: "Backend's title_suggestions (discover mode).",
6021
+ items: { type: "string" }
6022
+ },
6023
+ auto_included: {
6024
+ type: "array",
6025
+ description: "Backend's auto_included_titles (discover mode).",
6026
+ items: { type: "string" }
6027
+ },
6028
+ previously_enriched: {
6029
+ type: "array",
6030
+ description: "Titles previously enriched on this selection (discover mode).",
6031
+ items: { type: "string" }
6032
+ },
6033
+ enrichable_contacts: {
6034
+ type: "number",
6035
+ description: "Count of enrichable contacts at preview time."
6036
+ },
6037
+ selected_lead_count: {
6038
+ type: "number",
6039
+ description: "How many leads the selection covers."
6040
+ },
6041
+ preview: {
6042
+ type: "object",
6043
+ description: "Backend BulkEnrichPreview payload (preview_only/dry_run/launched modes)."
6044
+ },
6045
+ launched: {
6046
+ type: "boolean",
6047
+ description: "True when an enrichment job is in flight on the backend."
6048
+ },
6049
+ would_launch: {
6050
+ type: "object",
6051
+ description: "What dry_run WOULD have launched (titles, email, phone)."
6052
+ },
6053
+ re_used: {
6054
+ type: "boolean",
6055
+ description: "True when an identical bulk was launched within the idempotency window (already_launched mode)."
6056
+ },
6057
+ bulk_id: {
6058
+ type: "string",
6059
+ description: "UUIDv4 to poll via leadbay_bulk_enrich_status."
6060
+ },
6061
+ launched_at: {
6062
+ type: "string",
6063
+ description: "ISO timestamp of the (re-used or fresh) launch."
6064
+ },
6065
+ durability: {
6066
+ type: "string",
6067
+ description: "'file' (persisted bulks.json) or 'memory'."
6068
+ },
6069
+ seconds_since_original_launch: {
6070
+ type: "number",
6071
+ description: "Age of the re-used bulk record (already_launched mode)."
6072
+ },
6073
+ titles: {
6074
+ type: "array",
6075
+ description: "Titles ordered (echoed at launch).",
6076
+ items: { type: "string" }
6077
+ },
6078
+ email: { type: "boolean" },
6079
+ phone: { type: "boolean" },
6080
+ message: {
6081
+ type: "string",
6082
+ description: "Operator-facing summary of what happened."
6083
+ },
6084
+ next_action: {
6085
+ type: "string",
6086
+ description: "Concrete next-step instruction for the agent."
6087
+ },
6088
+ retry_after_seconds: {
6089
+ type: ["number", "null"],
6090
+ description: "Seconds until quota resets (quota_exceeded status)."
6091
+ }
4595
6092
  }
4596
6093
  },
4597
6094
  execute: async (client, params, ctx) => {
@@ -4622,11 +6119,21 @@ var enrichTitles = {
4622
6119
  hint: "Pass leadIds explicitly or wait for the wishlist to compute"
4623
6120
  };
4624
6121
  }
6122
+ ctx?.progress?.({
6123
+ progress: 1,
6124
+ total: 3,
6125
+ message: `Selecting ${leadIds.length} lead${leadIds.length === 1 ? "" : "s"}\u2026`
6126
+ });
4625
6127
  await client.acquireSelectionLock();
4626
6128
  try {
4627
6129
  const qs = leadIds.map((id) => `leadIds=${encodeURIComponent(id)}`).join("&");
4628
6130
  await client.requestVoid("POST", `/leads/selection/select?${qs}`);
4629
6131
  try {
6132
+ ctx?.progress?.({
6133
+ progress: 2,
6134
+ total: 3,
6135
+ message: "Previewing enrichment (titles + counts)\u2026"
6136
+ });
4630
6137
  const availableTitles = await client.request("GET", "/leads/selection/enrichment/job_titles");
4631
6138
  if (!params.titles || params.titles.length === 0) {
4632
6139
  let suggestions = [];
@@ -4720,14 +6227,24 @@ var enrichTitles = {
4720
6227
  };
4721
6228
  }
4722
6229
  }
6230
+ ctx?.progress?.({
6231
+ progress: 3,
6232
+ total: 3,
6233
+ message: `Launching enrichment for ${params.titles.length} title${params.titles.length === 1 ? "" : "s"}\u2026`
6234
+ });
4723
6235
  try {
4724
6236
  await client.requestVoid("POST", "/leads/selection/enrichment/launch", { titles: params.titles, email, phone });
4725
6237
  } catch (err) {
6238
+ const aborted = err?.name === "AbortError" || ctx?.signal?.aborted === true;
4726
6239
  if (bulkRecord && tracker) {
4727
6240
  try {
4728
- await tracker.markFailed(bulkRecord.bulk_id);
6241
+ if (aborted) {
6242
+ await tracker.markCancelled(bulkRecord.bulk_id);
6243
+ } else {
6244
+ await tracker.markFailed(bulkRecord.bulk_id);
6245
+ }
4729
6246
  } catch (e) {
4730
- ctx?.logger?.warn?.(`enrich_titles: tracker.markFailed failed: ${e?.message ?? e}`);
6247
+ ctx?.logger?.warn?.(`enrich_titles: tracker.${aborted ? "markCancelled" : "markFailed"} failed: ${e?.message ?? e}`);
4731
6248
  }
4732
6249
  }
4733
6250
  if (err?.code === "QUOTA_EXCEEDED") {
@@ -4804,6 +6321,13 @@ async function pMap(items, fn, concurrency) {
4804
6321
  }
4805
6322
  var bulkEnrichStatus = {
4806
6323
  name: "leadbay_bulk_enrich_status",
6324
+ annotations: {
6325
+ title: "Poll bulk-enrichment status",
6326
+ readOnlyHint: true,
6327
+ destructiveHint: false,
6328
+ idempotentHint: true,
6329
+ openWorldHint: true
6330
+ },
4807
6331
  description: "Check status + per-lead contacts for a bulk enrichment you previously launched via leadbay_enrich_titles. Returns the bulk_id, progress per lead (done/total enrichable contacts), and overall progress. When include_contacts=true (opt-in), includes each contact's email/phone/job_title/enrichment.done. When to use: poll this after leadbay_enrich_titles returns a bulk_id. Default include_contacts=false for cheap status polls; set include_contacts=true once all_done flips for the final read. When NOT to use: as a substitute for leadbay_research_lead \u2014 that already includes enriched contacts for a single lead.",
4808
6332
  inputSchema: {
4809
6333
  type: "object",
@@ -4817,7 +6341,55 @@ var bulkEnrichStatus = {
4817
6341
  description: "If true, return the full contact list per lead (email, phone, enrichment.done). Default false \u2014 cheap status polls."
4818
6342
  }
4819
6343
  },
4820
- required: ["bulk_id"]
6344
+ required: ["bulk_id"],
6345
+ additionalProperties: false
6346
+ },
6347
+ outputSchema: {
6348
+ type: "object",
6349
+ properties: {
6350
+ bulk_id: { type: "string", description: "Echoed UUIDv4 handle." },
6351
+ launched_at: { type: "string", description: "ISO timestamp of /enrichment/launch ack." },
6352
+ status: {
6353
+ type: "string",
6354
+ description: "'launched' on success. Errors return error envelopes (handled separately)."
6355
+ },
6356
+ durability: {
6357
+ type: "string",
6358
+ description: "'persistent' (file-backed bulks.json) or 'memory' (LEADBAY_BULK_STORE_ALLOW_MEMORY)."
6359
+ },
6360
+ titles: {
6361
+ type: "array",
6362
+ description: "Titles ordered at launch time (echoed from the original enrich_titles call).",
6363
+ items: { type: "string" }
6364
+ },
6365
+ email: { type: "boolean", description: "True if email enrichment was requested." },
6366
+ phone: { type: "boolean", description: "True if phone enrichment was requested." },
6367
+ lens_id: { type: "number", description: "Lens id used to scope the enrichment." },
6368
+ leads: {
6369
+ type: "array",
6370
+ description: "Per-lead rollup: {lead_id, enrichment_progress:{done,total}, contacts? (when include_contacts=true)}.",
6371
+ items: { type: "object" }
6372
+ },
6373
+ overall_progress: {
6374
+ type: "object",
6375
+ description: "Aggregate progress across all leads.",
6376
+ properties: {
6377
+ done: { type: "number" },
6378
+ total: { type: "number" },
6379
+ done_ratio: { type: "number" }
6380
+ }
6381
+ },
6382
+ all_done: {
6383
+ type: "boolean",
6384
+ description: "True when overall_progress.done === total AND no partial_failures."
6385
+ },
6386
+ partial_failures: {
6387
+ type: "array",
6388
+ description: "Per-lead errors observed during contacts fan-out (omitted when no failures).",
6389
+ items: { type: "object" }
6390
+ }
6391
+ },
6392
+ required: ["bulk_id", "status", "leads", "overall_progress", "all_done"]
4821
6393
  },
4822
6394
  execute: async (client, params, ctx) => {
4823
6395
  if (!isValidBulkId(params.bulk_id)) {
@@ -4886,6 +6458,18 @@ var bulkEnrichStatus = {
4886
6458
  launched_at: record.launched_at
4887
6459
  };
4888
6460
  }
6461
+ if (record.status === "cancelled") {
6462
+ return {
6463
+ error: true,
6464
+ code: "BULK_CANCELLED",
6465
+ message: "The bulk was cancelled (ctx.signal aborted by the client mid-launch). No further work is in flight.",
6466
+ hint: "Call leadbay_enrich_titles again with the same titles to relaunch \u2014 the cancelled record won't block a fresh launch.",
6467
+ bulk_id: record.bulk_id,
6468
+ launched_at: record.launched_at
6469
+ };
6470
+ }
6471
+ let doneSoFar = 0;
6472
+ const totalLeads = record.lead_ids.length;
4889
6473
  const results = await pMap(record.lead_ids, async (leadId) => {
4890
6474
  try {
4891
6475
  const out = await getContacts.execute(client, { leadId });
@@ -4893,6 +6477,12 @@ var bulkEnrichStatus = {
4893
6477
  const enrichable = contacts.filter((c) => c && c.enrichment);
4894
6478
  const done = enrichable.filter((c) => c.enrichment?.done === true).length;
4895
6479
  const total = enrichable.length;
6480
+ doneSoFar += 1;
6481
+ ctx?.progress?.({
6482
+ progress: doneSoFar,
6483
+ total: totalLeads,
6484
+ message: `Fetched contacts for ${leadId} (${doneSoFar}/${totalLeads})`
6485
+ });
4896
6486
  return {
4897
6487
  kind: "ok",
4898
6488
  lead_id: leadId,
@@ -4901,6 +6491,12 @@ var bulkEnrichStatus = {
4901
6491
  contacts: includeContacts ? contacts : void 0
4902
6492
  };
4903
6493
  } catch (err) {
6494
+ doneSoFar += 1;
6495
+ ctx?.progress?.({
6496
+ progress: doneSoFar,
6497
+ total: totalLeads,
6498
+ message: `Fetch failed for ${leadId} (${doneSoFar}/${totalLeads}): ${err?.code ?? "UNKNOWN"}`
6499
+ });
4904
6500
  return {
4905
6501
  kind: "fail",
4906
6502
  lead_id: leadId,
@@ -5041,6 +6637,17 @@ function mergeFilter(current, toAddSectors, toExcludeSectors, sizes) {
5041
6637
  }
5042
6638
  var adjustAudience = {
5043
6639
  name: "leadbay_adjust_audience",
6640
+ annotations: {
6641
+ title: "Adjust lens audience filters",
6642
+ readOnlyHint: false,
6643
+ destructiveHint: true,
6644
+ // Each call MERGES new criteria into the lens config; calling twice
6645
+ // with the same args produces the same final state (last write wins on
6646
+ // overlapping criteria, but the merge is deterministic). Per spec
6647
+ // idempotentHint is about same observable outcome — re-call is safe.
6648
+ idempotentHint: true,
6649
+ openWorldHint: true
6650
+ },
5044
6651
  description: "Restrict (or expand) the lens audience by sector / size. Free-text sectors are auto-resolved against the sector taxonomy; ambiguous matches are surfaced to the agent rather than guessed silently. Permission routing is hidden: the default lens auto-clones to a new user lens; an org-level lens defaults to a per-user draft (admins can override with save_for_org:true). Filter MERGES with existing criteria (unrelated criteria are not dropped). When to use: when the user wants to see different kinds of leads (sector / size / etc.). When NOT to use: to refine BEYOND firmographics \u2014 that's leadbay_refine_prompt.",
5045
6652
  inputSchema: {
5046
6653
  type: "object",
@@ -5077,7 +6684,34 @@ var adjustAudience = {
5077
6684
  type: "string",
5078
6685
  description: "Name to use when this composite has to clone the default lens (otherwise auto-named)"
5079
6686
  }
5080
- }
6687
+ },
6688
+ additionalProperties: false
6689
+ },
6690
+ outputSchema: {
6691
+ type: "object",
6692
+ description: "Two return shapes: 'ambiguous_sectors' when free-text sectors matched multiple candidates (agent re-calls with sector_ids), 'applied' on success.",
6693
+ properties: {
6694
+ status: {
6695
+ type: "string",
6696
+ description: "'ambiguous_sectors' or 'applied'."
6697
+ },
6698
+ sector_ambiguities: {
6699
+ type: "array",
6700
+ description: "Per ambiguous text: {sector_text, matches:[{id, name, score}]}. Agent picks an id and re-calls.",
6701
+ items: { type: "object" }
6702
+ },
6703
+ message: { type: "string" },
6704
+ lens_used: {
6705
+ type: "object",
6706
+ description: "Resolved lens metadata: {id, name, was_draft, was_new, save_for_org}."
6707
+ },
6708
+ filter_applied: {
6709
+ type: "object",
6710
+ description: "The merged FilterPayload that was POSTed to the lens."
6711
+ },
6712
+ _meta: { type: "object" }
6713
+ },
6714
+ required: ["status"]
5081
6715
  },
5082
6716
  execute: async (client, params, ctx) => {
5083
6717
  const me = await client.resolveMe();
@@ -5151,7 +6785,7 @@ var adjustAudience = {
5151
6785
  error: true,
5152
6786
  code: "ORPHAN_DRAFT",
5153
6787
  message: `Draft ${targetLensId} created but filter update failed; draft cleanup also failed`,
5154
- hint: `Manually delete draft lens ${targetLensId} via the Leadbay UI`,
6788
+ hint: `Use leadbay_promote_lens or leadbay_update_lens to recover, or open https://leadbay.app/lenses to manually delete draft lens ${targetLensId}.`,
5155
6789
  orphan_draft_id: targetLensId
5156
6790
  };
5157
6791
  }
@@ -5189,6 +6823,16 @@ var DEFAULT_POLL_ATTEMPTS = 2;
5189
6823
  var DEFAULT_POLL_GAP_MS = 5e3;
5190
6824
  var refinePrompt = {
5191
6825
  name: "leadbay_refine_prompt",
6826
+ annotations: {
6827
+ title: "Refine the audience prompt",
6828
+ readOnlyHint: false,
6829
+ destructiveHint: true,
6830
+ // Sets the org's user_prompt and may trigger a clarification flow. Each
6831
+ // call replaces the prior prompt — a second call with a different
6832
+ // instruction is NOT idempotent (the second prompt wins).
6833
+ idempotentHint: false,
6834
+ openWorldHint: true
6835
+ },
5192
6836
  description: "Refine the kind of leads Leadbay surfaces, beyond firmographics. Free-text instruction (e.g. 'focus on hospitals running their own IT'). Sets the org's user_prompt; if the new prompt produces ambiguous criteria, Leadbay raises a clarification question, which this composite polls for and surfaces. Admin-only on the backend (will return 403 for non-admins). When to use: when audience filters (leadbay_adjust_audience) aren't enough. When NOT to use: to answer a pending clarification \u2014 that's leadbay_answer_clarification.",
5193
6837
  inputSchema: {
5194
6838
  type: "object",
@@ -5207,7 +6851,44 @@ var refinePrompt = {
5207
6851
  description: "If true, return the call shape WITHOUT setting the prompt"
5208
6852
  }
5209
6853
  },
5210
- required: ["prompt"]
6854
+ required: ["prompt"],
6855
+ additionalProperties: false
6856
+ },
6857
+ outputSchema: {
6858
+ type: "object",
6859
+ description: "Multiple return shapes by status. dry_run, applied (with optional clarified_via_elicit), or clarification_pending.",
6860
+ properties: {
6861
+ dry_run: { type: "boolean", description: "True when dry_run:true was passed (no state change)." },
6862
+ would_call: {
6863
+ type: "object",
6864
+ description: "Dry-run preview of the POST that would have been issued."
6865
+ },
6866
+ status: {
6867
+ type: "string",
6868
+ description: "'applied' (prompt set; intelligence regenerating) or 'clarification_pending' (telephone path)."
6869
+ },
6870
+ computing_intelligence: {
6871
+ type: "boolean",
6872
+ description: "True when intelligence is regenerating after the prompt set."
6873
+ },
6874
+ clarified_via_elicit: {
6875
+ type: "boolean",
6876
+ description: "True when the clarification was answered via the client's elicitation UI (not via telephone)."
6877
+ },
6878
+ message: {
6879
+ type: "string",
6880
+ description: "Operator-facing summary."
6881
+ },
6882
+ clarification: {
6883
+ type: "object",
6884
+ description: "ClarificationPayload returned by the backend (clarification_pending path)."
6885
+ },
6886
+ next_action: {
6887
+ type: "string",
6888
+ description: "Concrete next-step instruction for the agent."
6889
+ },
6890
+ _meta: { type: "object" }
6891
+ }
5211
6892
  },
5212
6893
  execute: async (client, params, ctx) => {
5213
6894
  const me = await client.resolveMe();
@@ -5259,6 +6940,58 @@ var refinePrompt = {
5259
6940
  }
5260
6941
  }
5261
6942
  if (clarification) {
6943
+ if (ctx?.elicit) {
6944
+ const opts = clarification.options ?? [];
6945
+ const requestedSchema = opts.length > 0 ? {
6946
+ type: "object",
6947
+ properties: {
6948
+ option_id: {
6949
+ type: "string",
6950
+ title: "Pick one",
6951
+ description: "Choose the option that best matches your intent.",
6952
+ enum: opts.filter((o) => o.id).map((o) => o.id),
6953
+ enumNames: opts.filter((o) => o.id).map((o) => o.label)
6954
+ }
6955
+ },
6956
+ required: ["option_id"]
6957
+ } : {
6958
+ type: "object",
6959
+ properties: {
6960
+ text_answer: {
6961
+ type: "string",
6962
+ title: "Answer",
6963
+ description: "Free-text answer to the clarification. Plain English."
6964
+ }
6965
+ },
6966
+ required: ["text_answer"]
6967
+ };
6968
+ try {
6969
+ const elicited = await ctx.elicit({
6970
+ message: clarification.question,
6971
+ requestedSchema
6972
+ });
6973
+ if (elicited.action === "accept" && elicited.content) {
6974
+ const body = typeof elicited.content.option_id === "string" ? { option_id: elicited.content.option_id } : typeof elicited.content.text_answer === "string" ? { text_answer: elicited.content.text_answer } : null;
6975
+ if (body) {
6976
+ try {
6977
+ await client.requestVoid("POST", `/organizations/${orgId}/pick_clarification`, body);
6978
+ client.invalidateMe();
6979
+ return {
6980
+ status: "applied",
6981
+ clarified_via_elicit: true,
6982
+ computing_intelligence: true,
6983
+ message: "Prompt set + clarification answered via the client's elicitation UI. Leadbay is regenerating intelligence.",
6984
+ _meta: { region: client.region }
6985
+ };
6986
+ } catch (err) {
6987
+ ctx?.logger?.warn?.(`refine_prompt: pick_clarification POST failed after elicit: ${err?.message ?? err?.code ?? err}`);
6988
+ }
6989
+ }
6990
+ }
6991
+ } catch (err) {
6992
+ ctx?.logger?.warn?.(`refine_prompt: elicit failed: ${err?.message ?? err?.code ?? err} \u2014 falling back to telephone path`);
6993
+ }
6994
+ }
5262
6995
  return {
5263
6996
  status: "clarification_pending",
5264
6997
  clarification,
@@ -5278,13 +7011,44 @@ var refinePrompt = {
5278
7011
  // ../core/dist/composite/answer-clarification.js
5279
7012
  var answerClarification = {
5280
7013
  name: "leadbay_answer_clarification",
7014
+ annotations: {
7015
+ title: "Answer pending clarification",
7016
+ readOnlyHint: false,
7017
+ destructiveHint: true,
7018
+ // Records a one-time answer that becomes the new user_prompt and
7019
+ // triggers regeneration. Re-calling with a different answer wins;
7020
+ // not idempotent.
7021
+ idempotentHint: false,
7022
+ openWorldHint: true
7023
+ },
5281
7024
  description: "Answer the pending clarification question Leadbay raised after a refine_prompt. The answer is stored as the new user_prompt and triggers regeneration. Pass option_id (preferred \u2014 pick from the offered options) or text_answer (free-text). Admin-only. When to use: after leadbay_refine_prompt returns status='clarification_pending'. When NOT to use: to set a brand-new prompt \u2014 use leadbay_refine_prompt.",
5282
7025
  inputSchema: {
5283
7026
  type: "object",
5284
7027
  properties: {
5285
7028
  option_id: { type: "string", description: "Id of one of the clarification's options" },
5286
7029
  text_answer: { type: "string", description: "Free-text answer (overrides option_id)" }
5287
- }
7030
+ },
7031
+ additionalProperties: false
7032
+ },
7033
+ outputSchema: {
7034
+ type: "object",
7035
+ properties: {
7036
+ status: {
7037
+ type: "string",
7038
+ description: "'answered' (recorded; intelligence regenerating) or 'no_pending_clarification' (nothing to answer)."
7039
+ },
7040
+ recorded_as_user_prompt: {
7041
+ type: "boolean",
7042
+ description: "True when the answer was stored as the org's new user_prompt."
7043
+ },
7044
+ message: { type: "string" },
7045
+ hint: {
7046
+ type: "string",
7047
+ description: "Operator-facing next-step (no_pending_clarification path)."
7048
+ },
7049
+ _meta: { type: "object" }
7050
+ },
7051
+ required: ["status"]
5288
7052
  },
5289
7053
  execute: async (client, params, ctx) => {
5290
7054
  if (!params.option_id && !params.text_answer) {
@@ -5341,6 +7105,13 @@ function formatNoteWithVerification(note, v) {
5341
7105
  }
5342
7106
  var reportOutreach = {
5343
7107
  name: "leadbay_report_outreach",
7108
+ annotations: {
7109
+ title: "Report outreach to Leadbay",
7110
+ readOnlyHint: false,
7111
+ destructiveHint: true,
7112
+ idempotentHint: false,
7113
+ openWorldHint: true
7114
+ },
5344
7115
  description: "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.). VERIFICATION REQUIRED: every call must include verification={source: 'gmail_message_id'|'calendar_event_id'|'user_confirmed', ref: '<id-or-confirmation>'} to prevent hallucinated outreach poisoning the pipeline. The verification is appended to the note body. Bulk variant: pass lead_ids=[uuid,...] instead of lead_id (epilogue is bulk-native; notes fan out per-lead). When to use: AFTER actually emailing/calling/meeting/messaging a contact, OR after a substantive decision the user wants logged (skip, save, hand off). When NOT to use: BEFORE doing the outreach (use dry_run:true to validate args first); without verification (call will be rejected); from a flow where the user did not consent to having actions logged automatically.",
5345
7116
  optional: true,
5346
7117
  write: true,
@@ -5368,14 +7139,78 @@ var reportOutreach = {
5368
7139
  source: { type: "string" },
5369
7140
  ref: { type: "string" }
5370
7141
  },
5371
- required: ["source", "ref"]
7142
+ required: ["source", "ref"],
7143
+ // Security-load-bearing: the verification field prevents the agent from
7144
+ // poisoning the SDR pipeline with hallucinated outreach. Extra keys here
7145
+ // would create an injection vector (e.g., agent passes
7146
+ // verification.bypass="true"). Hard-rejected per second-opinion #3.
7147
+ additionalProperties: false
5372
7148
  },
5373
7149
  dry_run: {
5374
7150
  type: "boolean",
5375
7151
  description: "If true, return what WOULD be called without writing anything"
5376
7152
  }
5377
7153
  },
5378
- required: ["note", "verification"]
7154
+ required: ["note", "verification"],
7155
+ additionalProperties: false
7156
+ },
7157
+ outputSchema: {
7158
+ type: "object",
7159
+ description: "Either the dry_run shape (dry_run:true with would_write_notes / would_set_epilogue) OR the live result (notes:{succeeded,failed} + epilogue:{status,applied,error?} + verification + _meta). Schema declares both shapes; the dry_run discriminator picks which sub-shape applies.",
7160
+ properties: {
7161
+ // dry_run discriminator + dry_run subshape (from before iter 13)
7162
+ dry_run: { type: "boolean" },
7163
+ would_write_notes: {
7164
+ type: "array",
7165
+ description: "On dry_run: the per-lead POST shapes that WOULD be issued.",
7166
+ items: { type: "object" }
7167
+ },
7168
+ would_set_epilogue: {
7169
+ type: ["object", "null"],
7170
+ description: "On dry_run: the epilogue POST shape that WOULD be issued."
7171
+ },
7172
+ // Live subshape — what execute() actually returns when dry_run is false.
7173
+ notes: {
7174
+ type: "object",
7175
+ description: "Per-lead note-write outcome (split into succeeded / failed sub-arrays).",
7176
+ properties: {
7177
+ succeeded: {
7178
+ type: "array",
7179
+ items: { type: "object" }
7180
+ },
7181
+ failed: {
7182
+ type: "array",
7183
+ items: { type: "object" }
7184
+ }
7185
+ }
7186
+ },
7187
+ epilogue: {
7188
+ type: "object",
7189
+ description: "Epilogue status outcome: status (the wire-format string written to /leads/epilogue, or null when not requested), applied (true/false), error (when applied=false).",
7190
+ properties: {
7191
+ status: { type: ["string", "null"] },
7192
+ applied: { type: "boolean" },
7193
+ error: { type: "string" }
7194
+ }
7195
+ },
7196
+ verification: {
7197
+ type: "object",
7198
+ description: `Effective verification used (after elicit override). Useful for the client UI to render "logged with proof X". When source=user_confirmed AND ctx.elicit was available, ref is the user's literal text typed into the client; otherwise it's the agent-supplied ref.`,
7199
+ properties: {
7200
+ source: { type: "string" },
7201
+ ref: { type: "string" }
7202
+ }
7203
+ },
7204
+ confirmed_via: {
7205
+ type: "string",
7206
+ description: "Audit trail of how verification was obtained: 'elicit' (user typed into client UI \u2014 anti-poisoning), 'agent_supplied' (legacy path; user_confirmed source with no elicit), 'non_user_confirmed' (gmail_message_id or calendar_event_id \u2014 agent can't fabricate these)."
7207
+ },
7208
+ _meta: {
7209
+ type: "object",
7210
+ description: "Operator context: region.",
7211
+ properties: { region: { type: "string" } }
7212
+ }
7213
+ }
5379
7214
  },
5380
7215
  execute: async (client, params, ctx) => {
5381
7216
  if (!params.verification || !params.verification.source || !params.verification.ref) {
@@ -5386,6 +7221,16 @@ var reportOutreach = {
5386
7221
  hint: "Provide verification.source as one of: gmail_message_id (the Gmail message id from sending), calendar_event_id (the event id from booking), or user_confirmed (set verification.ref to the user's literal confirmation in chat)."
5387
7222
  };
5388
7223
  }
7224
+ const verificationKeys = Object.keys(params.verification);
7225
+ const extraKeys = verificationKeys.filter((k) => k !== "source" && k !== "ref");
7226
+ if (extraKeys.length > 0) {
7227
+ return {
7228
+ error: true,
7229
+ code: "VERIFICATION_EXTRA_KEYS",
7230
+ message: `verification accepts only {source, ref}; rejected extra key(s): ${extraKeys.join(", ")}`,
7231
+ hint: "Drop the extra key(s). Verification is security-sensitive \u2014 extra fields are not silently accepted."
7232
+ };
7233
+ }
5389
7234
  if (!VALID_SOURCES.has(params.verification.source)) {
5390
7235
  return {
5391
7236
  error: true,
@@ -5399,10 +7244,59 @@ var reportOutreach = {
5399
7244
  error: true,
5400
7245
  code: "BAD_INPUT",
5401
7246
  message: "Provide lead_id (single) or lead_ids (bulk)",
5402
- hint: "lead_id for one lead; lead_ids: [uuid, ...] for many"
7247
+ hint: "Set lead_id to one UUID for a single-lead call, or pass lead_ids: [uuid, ...] for a bulk call. Use leadbay_pull_leads to discover candidate IDs."
5403
7248
  };
5404
7249
  }
5405
- const noteBody = formatNoteWithVerification(params.note, params.verification);
7250
+ let confirmedVia = params.verification.source === "user_confirmed" ? "agent_supplied" : "non_user_confirmed";
7251
+ let effectiveVerification = params.verification;
7252
+ if (!params.dry_run && params.verification.source === "user_confirmed" && typeof ctx?.elicit === "function") {
7253
+ try {
7254
+ const targetIds = params.lead_ids ?? [params.lead_id];
7255
+ const leadCount = targetIds.length;
7256
+ const elicitMsg = leadCount === 1 ? `An AI agent wants to log outreach on lead ${targetIds[0]}: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.` : `An AI agent wants to log outreach on ${leadCount} leads: "${params.note}". The agent claims you confirmed this. Type your literal confirmation to proceed; cancel to reject.`;
7257
+ const result = await ctx.elicit({
7258
+ message: elicitMsg,
7259
+ requestedSchema: {
7260
+ type: "object",
7261
+ properties: {
7262
+ confirmation: {
7263
+ type: "string",
7264
+ title: "Your confirmation",
7265
+ description: "Type a few words confirming the outreach actually happened. This text becomes the audit-trail entry."
7266
+ }
7267
+ },
7268
+ required: ["confirmation"]
7269
+ }
7270
+ });
7271
+ if (result.action === "accept") {
7272
+ const userText = String(result.content?.confirmation ?? "").trim();
7273
+ if (userText.length > 0) {
7274
+ effectiveVerification = {
7275
+ source: "user_confirmed",
7276
+ ref: userText
7277
+ };
7278
+ confirmedVia = "elicit";
7279
+ } else {
7280
+ return {
7281
+ error: true,
7282
+ code: "OUTREACH_USER_CANCELLED",
7283
+ message: "User confirmation was empty; outreach not logged.",
7284
+ hint: "Re-call leadbay_report_outreach after the user types a non-empty confirmation, or use a gmail_message_id / calendar_event_id source instead."
7285
+ };
7286
+ }
7287
+ } else {
7288
+ return {
7289
+ error: true,
7290
+ code: "OUTREACH_USER_CANCELLED",
7291
+ message: `User ${result.action === "decline" ? "declined" : "cancelled"} the outreach confirmation; nothing was logged.`,
7292
+ hint: "Re-call leadbay_report_outreach with verification.source set to gmail_message_id or calendar_event_id when the user is unwilling to type a confirmation."
7293
+ };
7294
+ }
7295
+ } catch (err) {
7296
+ ctx?.logger?.warn?.(`report_outreach: ctx.elicit failed (${err?.code ?? err?.message ?? err}) \u2014 falling back to agent-supplied verification`);
7297
+ }
7298
+ }
7299
+ const noteBody = formatNoteWithVerification(params.note, effectiveVerification);
5406
7300
  let epilogueWire = null;
5407
7301
  if (params.epilogue_status) {
5408
7302
  const w = EPILOGUE_LABEL_MAP[params.epilogue_status];
@@ -5469,7 +7363,17 @@ var reportOutreach = {
5469
7363
  status: epilogueWire,
5470
7364
  ...epilogueResult
5471
7365
  },
5472
- verification: params.verification,
7366
+ verification: effectiveVerification,
7367
+ // iter-22: audit-trail field. Tells the SDR team which path was taken
7368
+ // for this call:
7369
+ // "elicit" = the user typed the confirmation directly via the
7370
+ // client UI (anti-poisoning shape).
7371
+ // "agent_supplied" = source was user_confirmed but ctx.elicit was
7372
+ // unavailable / failed; agent's ref was accepted.
7373
+ // "non_user_confirmed" = source was gmail_message_id or
7374
+ // calendar_event_id (agent doesn't get to fabricate
7375
+ // these — they're external ids).
7376
+ confirmed_via: confirmedVia,
5473
7377
  _meta: { region: client.region }
5474
7378
  };
5475
7379
  }