@sellable/mcp 0.1.148 → 0.1.150

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.
package/README.md CHANGED
@@ -267,10 +267,14 @@ Parallel execution contract:
267
267
  Codex config, Claude agent files, and prompts should consume the registry.
268
268
  - Post-find-leads scout names also come from `agents/registry.json` and are
269
269
  exposed through `get_post_find_leads_scout_registry`. After
270
- `confirm_lead_list` imports a non-empty review batch and rows are proven, use
271
- that registry to launch the message-generation scout immediately when the host
272
- supports real subagents. Launch the filter-leads scout later, only after the
273
- user chooses filters.
270
+ `confirm_lead_list` imports a non-empty review batch and rows are proven, ask
271
+ the filter-choice question immediately. Do not load this registry or deep
272
+ filter/message prompts before that question. Once the user answers, launch the
273
+ message-generation scout from the same campaign/table basis. If the user
274
+ chooses filters, also launch the filter-leads scout, show Filter Rules, save
275
+ rubrics, then keep the browser on Filter Leads while the message
276
+ recommendation is reviewed. If filters are skipped, move to Messages/message
277
+ review. Product cells still wait for template approval.
274
278
  - Claude host: use the installed `source-scout-linkedin-engagement`,
275
279
  `source-scout-sales-nav`, and `source-scout-prospeo-contact` Task/Agent
276
280
  subagents for parallel lead-source scouting only when the current session
@@ -1,6 +1,6 @@
1
1
  You are the Prospeo Contact Scout for Sellable find-leads.
2
2
 
3
- Your job is to test whether Prospeo can produce verified-contact scale for the campaign through account/domain targeting or broad persona expansion. Work only on this source lane. Do not import leads, create campaigns, write campaign artifacts, draft messages, ask the user questions, or make the final source decision.
3
+ Your job is to test whether Prospeo can produce verified-contact scale for the campaign through account/domain targeting, hiring-led company job-posting filters, or broad persona expansion. Work only on this source lane. Do not import leads, create campaigns, write campaign artifacts, draft messages, ask the user questions, or make the final source decision.
4
4
 
5
5
  Required first step:
6
6
 
@@ -25,10 +25,12 @@ Use the inherited Sellable MCP tools when available:
25
25
  Process:
26
26
 
27
27
  1. Read the campaign brief, source intake, kickoff doc, or lane prompt supplied by the parent.
28
- 2. Identify whether this is domain/account targeting or broad persona expansion.
28
+ 2. Identify whether this is domain/account targeting, hiring-led targeting, or broad persona expansion.
29
29
  3. For domain targeting, use or create the standalone `domainFilterId` before searching; never pass raw domains directly into `search_prospeo`.
30
- 4. Run the narrowest useful Prospeo people preview and 1-2 refinements if quality or scale is unclear. Check scale against a default 300+ good-fit target, capped at 2,500 source candidates unless the parent supplies a different target, and require at least 10% projected good-fit after cleanup.
31
- 5. Call out that Prospeo gives contact/account coverage but usually weaker LinkedIn intent than LinkedIn Engagement or Sales Nav activity slices.
30
+ 4. For hiring-led targeting, use `company_job_posting_hiring_for` for the target open-role themes and `company_job_posting_quantity` when the brief needs an active hiring floor. Pair those company hiring filters with buyer/referrer person filters; do not treat hiring-led targeting as Sales Nav-only.
31
+ 5. Run the narrowest useful Prospeo people preview and 1-2 refinements if quality or scale is unclear. Check scale against the source target good-fit lead count (default about 150 usable prospects unless the parent supplies a different target) and cap source candidates at the provider limit. Use the first-page sample to compute projected good fits from a source-list export, not to recommend a 25-row import.
32
+ 6. If `raw_result_count * projected_fit_rate_after_cleanup` is below the source target, do not recommend import yet. Tighten or broaden filters and retry until the projected usable pool clears target, or clearly report that the lane is too constrained.
33
+ 7. Call out that Prospeo gives contact/account and hiring-signal coverage but usually weaker LinkedIn intent than LinkedIn Engagement or Sales Nav activity slices.
32
34
 
33
35
  Return a concise structured result with:
34
36
 
@@ -40,6 +42,7 @@ Return a concise structured result with:
40
42
  - `raw_result_count`
41
43
  - `sampled_people` and good fits as n/N
42
44
  - `estimated_good_fit_range_after_cleanup`
45
+ - `source_export_math` with target good-fit count, conservative projected fit rate, recommended `targetLeadCount` for `import_leads`, and projected good fits from that export
43
46
  - `expected_reply_rate_range`, directional if inferred
44
47
  - `sample_leads`
45
48
  - `false_positive_patterns`
@@ -53,4 +56,8 @@ Evidence standards:
53
56
  - Prospeo is the terminal fallback. If projected good-fit after cleanup remains
54
57
  below 10% after reasonable refinement, recommend tightening the ICP/source
55
58
  direction rather than switching providers again.
56
- - Treat Prospeo as an account/contact coverage lane, not as proof of fresh LinkedIn intent.
59
+ - Never recommend "import 25 leads" as the Prospeo source action. Recommend
60
+ exporting/materializing the source list with the approved `targetLeadCount`;
61
+ the parent thread later clones the bounded review batch with
62
+ `confirm_lead_list`.
63
+ - Treat Prospeo as an account/contact and company hiring-signal lane, not as proof of fresh LinkedIn intent.
@@ -28,8 +28,9 @@ Process:
28
28
  2. Preserve target role names with `CURRENT_TITLE` lookups; do not rely on seniority alone when the brief names concrete roles.
29
29
  3. When `lookup_sales_nav_filter` returns multiple title options, choose the closest semantic title match instead of the first result.
30
30
  4. Build a broad-but-reasonable baseline from role/title, geography, company size, industry/account context, and recent LinkedIn activity when relevant.
31
- 5. Check scale against the source target good-fit lead count (default 300+
32
- for Sales Nav, capped at 2,500 source candidates unless the parent supplies a target).
31
+ 5. Check scale against the source target good-fit lead count (default about
32
+ 150 usable prospects for Sales Nav unless the parent supplies a target,
33
+ capped at 2,500 source candidates).
33
34
  If raw preview volume or projected usable volume
34
35
  is below target, do not present the tiny result as the scale fallback yet.
35
36
  Loosen nonessential filters in order: remove recent-activity first, widen
@@ -41,7 +42,10 @@ Process:
41
42
  6. Run the baseline plus 1-2 refinements or loosening passes if the first pass
42
43
  is noisy or under-scaled. Label the final pool as constrained if it still
43
44
  cannot plausibly reach the target after loosening.
44
- 7. Verify filters actually applied: returned search URL contains filters, first-page rows match the intended lane, and result count does not look like an unfiltered pool.
45
+ 7. Use the first-page sample to compute projected good fits from the source-list
46
+ export. The recommendation should name the source-list `targetLeadCount` for
47
+ `import_leads`, not a 25-row review-batch import.
48
+ 8. Verify filters actually applied: returned search URL contains filters, first-page rows match the intended lane, and result count does not look like an unfiltered pool.
45
49
 
46
50
  Return a concise structured result with:
47
51
 
@@ -53,6 +57,8 @@ Return a concise structured result with:
53
57
  - `scale_check` with source target good-fit lead count, preview/raw volume, sampled
54
58
  good fits as n/N, projected usable count, and whether the pool can reach the
55
59
  target
60
+ - `source_export_math` with conservative projected fit rate, recommended
61
+ `targetLeadCount` for `import_leads`, and projected good fits from that export
56
62
  - `loosening_attempts` with what was removed or widened when the pool was too
57
63
  tight
58
64
  - `sampled_people` and good fits as n/N
@@ -74,5 +80,9 @@ Evidence standards:
74
80
  name the next broadening/Prospeo option.
75
81
  - If projected good-fit after cleanup is below 10%, do not recommend Sales Nav
76
82
  as the winning source; recommend Prospeo as the next provider.
83
+ - Never recommend "import 25 leads" as the Sales Nav source action. Recommend
84
+ exporting/materializing the source list with the approved `targetLeadCount`;
85
+ the parent thread later clones the bounded review batch with
86
+ `confirm_lead_list`.
77
87
  - Do not hand-wave missing filter IDs.
78
88
  - If Sales Nav returns a giant unfiltered pool, discard that result and retry with valid filters before recommending it.
package/dist/index-dev.js CHANGED
File without changes
package/dist/index.js CHANGED
File without changes
@@ -9,6 +9,9 @@ export declare function selectSignalPostsForImport<T extends SignalPostForImport
9
9
  }): {
10
10
  posts: T[];
11
11
  estimatedEngagers: number;
12
+ availableEngagers: number;
13
+ targetEngagerCount: number | null;
14
+ targetReached: boolean;
12
15
  limited: boolean;
13
16
  };
14
17
  export type GetProviderPromptInput = {
@@ -1397,10 +1400,11 @@ export declare const leadToolDefinitions: ({
1397
1400
  items: {
1398
1401
  type: string;
1399
1402
  };
1403
+ description: string;
1400
1404
  };
1401
1405
  company_job_posting_quantity: {
1402
- type: string;
1403
1406
  description: string;
1407
+ type: string;
1404
1408
  properties: {
1405
1409
  min: {
1406
1410
  type: string;
@@ -8,6 +8,7 @@ import { buildCsvLinkedinPreview, matchesLinkedinConfirmationToken, parseLinkedi
8
8
  import { assertInteractionApproval } from "./interaction-mode.js";
9
9
  import { assertProviderPromptLoaded, markProviderPromptLoaded, } from "./provider-preflight.js";
10
10
  import { waitForLeadListReady } from "./readiness.js";
11
+ import { getTableRowsMinimal } from "./rows.js";
11
12
  const entryPath = process.argv[1] ? resolve(process.argv[1]) : process.cwd();
12
13
  const entryDir = dirname(entryPath);
13
14
  const workspaceRoot = resolveWorkspaceRoot(entryDir);
@@ -42,11 +43,11 @@ const defaultCampaignSourceDefaults = {
42
43
  postCoverageBuffer: 1.2,
43
44
  },
44
45
  "sales-nav": {
45
- targetGoodFitLeads: 300,
46
+ targetGoodFitLeads: 150,
46
47
  maxSourceCandidates: 2500,
47
48
  },
48
49
  prospeo: {
49
- targetGoodFitLeads: 300,
50
+ targetGoodFitLeads: 150,
50
51
  maxSourceCandidates: 2500,
51
52
  },
52
53
  },
@@ -343,13 +344,18 @@ export function selectSignalPostsForImport(posts, options) {
343
344
  const normalizedTargetEngagers = normalizePositiveInteger(options.targetEngagerCount);
344
345
  const normalizedMaxPosts = normalizePositiveInteger(options.maxPostsToScrape);
345
346
  if (!normalizedTargetEngagers && !normalizedMaxPosts) {
347
+ const availableEngagers = posts.reduce((sum, post) => sum + post.likes + post.comments, 0);
346
348
  return {
347
349
  posts,
348
- estimatedEngagers: posts.reduce((sum, post) => sum + post.likes + post.comments, 0),
350
+ estimatedEngagers: availableEngagers,
351
+ availableEngagers,
352
+ targetEngagerCount: null,
353
+ targetReached: true,
349
354
  limited: false,
350
355
  };
351
356
  }
352
357
  const ranked = [...posts].sort((a, b) => b.likes + b.comments - (a.likes + a.comments));
358
+ const availableEngagers = ranked.reduce((sum, post) => sum + post.likes + post.comments, 0);
353
359
  const selected = [];
354
360
  let estimatedEngagers = 0;
355
361
  for (const post of ranked) {
@@ -366,6 +372,10 @@ export function selectSignalPostsForImport(posts, options) {
366
372
  return {
367
373
  posts: selected.length > 0 ? selected : ranked,
368
374
  estimatedEngagers,
375
+ availableEngagers,
376
+ targetEngagerCount: normalizedTargetEngagers ?? null,
377
+ targetReached: !normalizedTargetEngagers ||
378
+ estimatedEngagers >= normalizedTargetEngagers,
369
379
  limited: selected.length > 0 && selected.length < posts.length,
370
380
  };
371
381
  }
@@ -516,8 +526,8 @@ function buildSourceImportWatchNarration({ provider, selectedPostCount, estimate
516
526
  : "Importing source leads",
517
527
  visibleState: `The browser is showing ${providerLabel} import progress for ${sourceDetail}.${targetDetail}`,
518
528
  agentIntent: "Codex is materializing the approved source into a lead list before cloning only the bounded review batch into the campaign.",
519
- nextAction: "Wait for source leads, then import the 15-row review batch",
520
- safety: "Import is limited to the review batch.",
529
+ nextAction: "Wait for source leads, then import the bounded review batch",
530
+ safety: "This step materializes the source list; only the bounded review batch is cloned into the campaign afterward.",
521
531
  progressLabel: "Source scouting",
522
532
  };
523
533
  }
@@ -549,19 +559,146 @@ function buildSourceImportRecoveryWatchNarration(args) {
549
559
  safety: "The campaign is still at source recovery.",
550
560
  };
551
561
  }
552
- function buildFilterChoiceWatchNarration({ sourceLeadCount, reviewRowCount, }) {
562
+ function normalizeLeadText(value) {
563
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
564
+ }
565
+ function compactLeadText(value) {
566
+ return typeof value === "string" ? value.trim().replace(/\s+/g, " ") : "";
567
+ }
568
+ function rowSearchText(row) {
569
+ const carryValues = Object.values(row.carryData ?? {}).slice(0, 12);
570
+ return [row.name, row.title, row.company, ...carryValues]
571
+ .map(normalizeLeadText)
572
+ .filter(Boolean)
573
+ .join(" ");
574
+ }
575
+ function rowExample(row) {
576
+ const name = compactLeadText(row.name);
577
+ const title = compactLeadText(row.title);
578
+ const company = compactLeadText(row.company);
579
+ const label = name || company || title || "Unnamed lead";
580
+ return title ? `${label} (${title})` : label;
581
+ }
582
+ function collectExamples(rows, indexes) {
583
+ return indexes.slice(0, 2).map((index) => rowExample(rows[index]));
584
+ }
585
+ function formatExamples(prefix, examples) {
586
+ if (examples.length === 0)
587
+ return "";
588
+ const text = `${prefix} ${examples.join("; ")}.`;
589
+ return text.length > 180 ? `${text.slice(0, 177).trim()}...` : text;
590
+ }
591
+ function analyzeFilterChoiceSample(sampleRows) {
592
+ const rows = Array.isArray(sampleRows) ? sampleRows : [];
593
+ const strongIndexes = [];
594
+ const adjacentIndexes = [];
595
+ const riskIndexes = [];
596
+ const unknownIndexes = [];
597
+ const seniorPattern = /\b(founder|co[-\s]?founder|ceo|chief|cro|cmo|cco|vp|vice president|head of|director|owner|partner)\b/;
598
+ const gtmPattern = /\b(gtm|go[-\s]?to[-\s]?market|revenue|sales|growth|marketing|demand gen|demand generation|business development|bd\b|outbound|pipeline|revops|revenue operations|sales development|sdr)\b/;
599
+ const adjacentPattern = /\b(sales engineering|solutions? engineering|enablement|customer success|partnerships|partner|product marketing|marketing operations|sales operations)\b/;
600
+ const recruitingPattern = /\b(recruiter|recruiting|talent acquisition|people operations|human resources|\bhr\b)\b/;
601
+ const juniorOrNoisyPattern = /\b(intern|student|associate|coordinator|assistant|freelance|consultant|self-employed|open to work)\b/;
602
+ rows.forEach((row, index) => {
603
+ const text = rowSearchText(row);
604
+ if (!text) {
605
+ unknownIndexes.push(index);
606
+ return;
607
+ }
608
+ if (recruitingPattern.test(text) || juniorOrNoisyPattern.test(text)) {
609
+ riskIndexes.push(index);
610
+ return;
611
+ }
612
+ if (seniorPattern.test(text) && gtmPattern.test(text)) {
613
+ strongIndexes.push(index);
614
+ return;
615
+ }
616
+ if (seniorPattern.test(text) && adjacentPattern.test(text)) {
617
+ adjacentIndexes.push(index);
618
+ return;
619
+ }
620
+ unknownIndexes.push(index);
621
+ });
622
+ const sampleCount = rows.length;
623
+ if (sampleCount === 0) {
624
+ return {
625
+ sampleCount,
626
+ strongFitCount: 0,
627
+ adjacentCount: 0,
628
+ riskCount: 0,
629
+ unknownCount: 0,
630
+ strongExamples: [],
631
+ riskExamples: [],
632
+ recommendation: "manual-review",
633
+ };
634
+ }
635
+ const fitCount = strongIndexes.length + adjacentIndexes.length;
636
+ const riskCount = riskIndexes.length;
637
+ const unknownCount = unknownIndexes.length;
638
+ const cleanupCount = riskCount + unknownCount;
639
+ const fitRatio = fitCount / sampleCount;
640
+ const cleanupRatio = cleanupCount / sampleCount;
641
+ const adjacentRatio = adjacentIndexes.length / sampleCount;
642
+ const maxCleanRiskCount = Math.max(2, Math.floor(sampleCount * 0.12));
643
+ const recommendation = fitRatio >= 0.75 &&
644
+ cleanupCount <= maxCleanRiskCount &&
645
+ adjacentRatio <= 0.3
646
+ ? "skip"
647
+ : fitRatio >= 0.6 && cleanupRatio <= 0.3
648
+ ? "light-filter"
649
+ : "add-filter";
650
+ return {
651
+ sampleCount,
652
+ strongFitCount: strongIndexes.length,
653
+ adjacentCount: adjacentIndexes.length,
654
+ riskCount,
655
+ unknownCount,
656
+ strongExamples: collectExamples(rows, [
657
+ ...strongIndexes,
658
+ ...adjacentIndexes,
659
+ ]),
660
+ riskExamples: collectExamples(rows, [...riskIndexes, ...unknownIndexes]),
661
+ recommendation,
662
+ };
663
+ }
664
+ function buildFilterChoiceWatchNarration({ sourceLeadCount, reviewRowCount, sampleRows, }) {
553
665
  const sourceCopy = typeof sourceLeadCount === "number" && sourceLeadCount > 0
554
666
  ? ` from ${sourceLeadCount.toLocaleString("en-US")} source candidate${sourceLeadCount === 1 ? "" : "s"}`
555
667
  : "";
556
668
  const reviewCopy = typeof reviewRowCount === "number" && reviewRowCount > 0
557
669
  ? `${reviewRowCount.toLocaleString("en-US")} review lead${reviewRowCount === 1 ? " is" : "s are"} in the campaign table${sourceCopy}.`
558
670
  : `The bounded review batch is in the campaign table${sourceCopy}.`;
671
+ const assessment = analyzeFilterChoiceSample(sampleRows);
672
+ const fitCount = assessment.strongFitCount + assessment.adjacentCount;
673
+ const cleanupCount = assessment.riskCount + assessment.unknownCount;
674
+ const sampleCopy = assessment.sampleCount > 0
675
+ ? `Sample read: ${fitCount}/${assessment.sampleCount} look like senior target-role fits; ${cleanupCount} need cleanup or manual review.`
676
+ : "Sample rows were not available to inspect, so this is a manual filter decision.";
677
+ const fitExamples = formatExamples("Fit examples:", assessment.strongExamples);
678
+ const riskExamples = formatExamples("Cleanup examples:", assessment.riskExamples);
679
+ const headline = assessment.recommendation === "skip"
680
+ ? "Filtering may not be needed"
681
+ : assessment.recommendation === "light-filter"
682
+ ? "I recommend light cleanup filters"
683
+ : assessment.recommendation === "manual-review"
684
+ ? "Review filters or skip"
685
+ : "I recommend adding filters";
686
+ const agentIntent = assessment.recommendation === "skip"
687
+ ? `Codex is not recommending filters because the sample already looks clean. ${fitExamples} Add filters only for a narrow exclusion.`
688
+ : assessment.recommendation === "light-filter"
689
+ ? `Codex is recommending light filters because most rows fit, but ${cleanupCount} of ${assessment.sampleCount} look adjacent, unknown, or risky. ${riskExamples}`
690
+ : assessment.recommendation === "manual-review"
691
+ ? "Codex could not inspect the review rows, so it is pausing for a manual filter decision instead of assuming filters are required."
692
+ : `Codex is recommending filters because ${cleanupCount} of ${assessment.sampleCount} sampled rows look adjacent, unknown, or risky before message review. ${riskExamples}`;
693
+ const nextAction = assessment.recommendation === "skip"
694
+ ? "Skip filters or add a narrow cleanup rule"
695
+ : "Choose filters or skip";
559
696
  return {
560
697
  stage: "fit-message",
561
- headline: "I recommend adding filters",
562
- visibleState: `${reviewCopy} The browser is showing the filter-choice screen and sample rows.`,
563
- agentIntent: "Codex is pausing on this sample because a mixed review list should be filtered before message review. Skip filters only if the visible rows already look clean.",
564
- nextAction: "Choose filters or skip",
698
+ headline,
699
+ visibleState: `${reviewCopy} ${sampleCopy} The browser is showing the filter-choice screen.`,
700
+ agentIntent: agentIntent.trim(),
701
+ nextAction,
565
702
  safety: "This choice sets the filter path.",
566
703
  workerStatuses: {
567
704
  leadFitBuilder: "idle",
@@ -1002,7 +1139,7 @@ export const leadToolDefinitions = [
1002
1139
  },
1003
1140
  {
1004
1141
  name: "search_prospeo",
1005
- description: 'Search Prospeo for people using filters and optional domainFilterId. Requires get_provider_prompt({ provider: "prospeo" }) first. When targeting known accounts, call load_csv_domains for CSV-on-disk workflows or save_domain_filters for pasted/raw domain lists, then pass domainFilterId. Raw domain inputs and company-name targeting are NOT supported in this MCP tool. Strategy: start with 2-3 high-signal filters (title/seniority + industry or domainFilterId + headcount), then tighten one filter at a time. For security, AppSec, SOC, RevOps, Demand Gen, and similar function-specific lanes, do not widen with bare seniority labels like "Head" or "Director" alone; pair them with explicit function-title keywords and inspect the sample for off-function `Head of X` leakage. Prefer person location over company HQ unless HQ is explicitly needed. `campaignOfferId` routing rule: OMIT campaignOfferId ONLY in pre-mint Phase 84 `find leads` discovery mode (validating ICP before the commit gate). In every other context — post-mint lead additions, operator-driven searches on a live campaign, any search where the intent is to persist results to a specific campaign — you MUST pass campaignOfferId so the search shows up in that campaign\'s Contact Search panel. Post-mint create-campaign-v2 watch runs MUST pass currentStep: "prospeo". Omitting campaignOfferId post-mint orphans the search from the UI. Returns normalized results with pagination.',
1142
+ description: 'Search Prospeo for people using filters and optional domainFilterId. Requires get_provider_prompt({ provider: "prospeo" }) first. Use Prospeo first for hiring-led targeting because it supports company_job_posting_hiring_for and company_job_posting_quantity; Sales Nav does not filter companies by hiring role. When targeting known accounts, call load_csv_domains for CSV-on-disk workflows or save_domain_filters for pasted/raw domain lists, then pass domainFilterId. Raw domain inputs and company-name targeting are NOT supported in this MCP tool. Strategy: start with 2-3 high-signal filters (title/seniority + industry or domainFilterId + headcount), add job-posting filters for hiring-led campaigns, then tighten one filter at a time. For security, AppSec, SOC, RevOps, Demand Gen, and similar function-specific lanes, do not widen with bare seniority labels like "Head" or "Director" alone; pair them with explicit function-title keywords and inspect the sample for off-function `Head of X` leakage. Prefer person location over company HQ unless HQ is explicitly needed. `campaignOfferId` routing rule: OMIT campaignOfferId ONLY in pre-mint Phase 84 `find leads` discovery mode (validating ICP before the commit gate). In every other context — post-mint lead additions, operator-driven searches on a live campaign, any search where the intent is to persist results to a specific campaign — you MUST pass campaignOfferId so the search shows up in that campaign\'s Contact Search panel. Post-mint create-campaign-v2 watch runs MUST pass currentStep: "prospeo". Omitting campaignOfferId post-mint orphans the search from the UI. Returns normalized results with pagination.',
1006
1143
  inputSchema: {
1007
1144
  type: "object",
1008
1145
  properties: {
@@ -1069,8 +1206,12 @@ export const leadToolDefinitions = [
1069
1206
  company_job_posting_hiring_for: {
1070
1207
  type: "array",
1071
1208
  items: { type: "string" },
1209
+ description: "Company hiring filter. Use for hiring-led campaigns; values are open-role themes such as SDR, Account Executive, Growth, RevOps, Demand Generation, GTM Engineer, or AI Engineer.",
1210
+ },
1211
+ company_job_posting_quantity: {
1212
+ ...prospeoRangeFilterSchema,
1213
+ description: "Active job posting count range. Use min when the campaign needs companies with a real hiring push.",
1072
1214
  },
1073
- company_job_posting_quantity: prospeoRangeFilterSchema,
1074
1215
  company_headcount_by_department: {
1075
1216
  type: "array",
1076
1217
  items: prospeoCompanyHeadcountByDepartmentSchema,
@@ -1183,7 +1324,7 @@ export const leadToolDefinitions = [
1183
1324
  },
1184
1325
  {
1185
1326
  name: "import_leads",
1186
- description: "Create/select a lead list and start the provider import job. Requires provider prompt preflight via get_provider_prompt for the active provider. On success, this tool owns moving the watched campaign to confirm-lead-list with import/progress narration after a leadListId/jobId exists; do not call update_campaign to fix that step. After the user confirms the list, call confirm_lead_list.",
1327
+ description: "Create/select a source lead list and start the provider import/export job. Requires provider prompt preflight via get_provider_prompt for the active provider. For Sales Nav/Prospeo, targetLeadCount is the approved source-list export count computed from projected good-fit math, not the later bounded review-batch size. On success, this tool owns moving the watched campaign to confirm-lead-list with import/progress narration after a leadListId/jobId exists; do not call update_campaign to fix that step. After the user confirms the source list, call confirm_lead_list to clone only the bounded review batch into the campaign table.",
1187
1328
  inputSchema: {
1188
1329
  type: "object",
1189
1330
  properties: {
@@ -1210,7 +1351,7 @@ export const leadToolDefinitions = [
1210
1351
  },
1211
1352
  targetLeadCount: {
1212
1353
  type: "number",
1213
- description: "Target number of leads (max per provider from config)",
1354
+ description: "Provider source-list target count (max per provider from config). For Sales Nav/Prospeo this should be the export/materialization count needed to hit projected good-fit goals, not the 25-row review-batch size.",
1214
1355
  },
1215
1356
  mode: {
1216
1357
  type: "string",
@@ -2312,12 +2453,19 @@ export async function importLeads(input) {
2312
2453
  maxPostsToScrape,
2313
2454
  });
2314
2455
  const postsToScrape = importSelection.posts;
2456
+ if (!importSelection.targetReached && importSelection.targetEngagerCount) {
2457
+ const capClause = importSelection.availableEngagers >= importSelection.targetEngagerCount
2458
+ ? ` The selected posts can cover the target, but maxPostsToScrape=${normalizePositiveInteger(maxPostsToScrape)} prevents reaching it. Increase maxPostsToScrape or remove that cap.`
2459
+ : " Select/promote more right-content posts, run another narrow Signal Discovery search, or switch to Sales Nav recent activity if the lane cannot produce enough source candidates.";
2460
+ throw new Error(`Signal Discovery selected posts only cover about ${importSelection.estimatedEngagers.toLocaleString("en-US")} visible engagers, below the approved ${importSelection.targetEngagerCount.toLocaleString("en-US")} source-candidate target. Do not scrape this under-capacity post set.${capClause}`);
2461
+ }
2315
2462
  const effectiveHeadlineICPCriteria = headlineICPCriteria && headlineICPCriteria.length > 0
2316
2463
  ? headlineICPCriteria
2317
2464
  : rubricGuidelines;
2318
2465
  // Start the scrape job
2319
2466
  const result = await api.post(`/api/v3/campaigns/${campaignOfferId}/signal-leads/create`, {
2320
2467
  posts: postsToScrape,
2468
+ targetEngagerCount: importSelection.targetEngagerCount ?? undefined,
2321
2469
  ...(effectiveHeadlineICPCriteria &&
2322
2470
  effectiveHeadlineICPCriteria.length > 0
2323
2471
  ? {
@@ -2577,6 +2725,19 @@ export async function confirmLeadList(input) {
2577
2725
  const leadListConfig = leadListMeta.table?.config ?? null;
2578
2726
  const leadListRowCount = leadListMeta.rowCount ?? 0;
2579
2727
  const importProgress = leadListConfig?.importProgress ?? null;
2728
+ const signalSourceTargetLeadCount = resolvedProvider === "signal-discovery" &&
2729
+ typeof leadListConfig?.targetLeadCount === "number" &&
2730
+ Number.isFinite(leadListConfig.targetLeadCount) &&
2731
+ leadListConfig.targetLeadCount > 0
2732
+ ? leadListConfig.targetLeadCount
2733
+ : null;
2734
+ if (resolvedProvider === "signal-discovery" &&
2735
+ signalSourceTargetLeadCount !== null &&
2736
+ leadListConfig?.importStatus === "complete" &&
2737
+ leadListRowCount > 0 &&
2738
+ leadListRowCount < signalSourceTargetLeadCount) {
2739
+ throw new Error(`Signal Discovery source list is under capacity: it completed with ${leadListRowCount.toLocaleString("en-US")} source candidates, below the approved ${signalSourceTargetLeadCount.toLocaleString("en-US")} source-candidate target. Do not import the bounded review batch from this source. Select more posts, rerun Signal Discovery, or move to Sales Nav/Prospeo.`);
2740
+ }
2580
2741
  const progressProcessed = typeof importProgress?.processed === "number"
2581
2742
  ? importProgress.processed
2582
2743
  : null;
@@ -2712,6 +2873,20 @@ export async function confirmLeadList(input) {
2712
2873
  await Promise.all(batch.map((rowId) => api.delete(`/api/v3/workflow-tables/${campaignTableId}/rows/${rowId}`)));
2713
2874
  }
2714
2875
  }
2876
+ let reviewSampleRows = [];
2877
+ if (campaignTableId && effectiveCurrentStep === "filter-choice") {
2878
+ try {
2879
+ const sampleLimit = Math.min(Math.max(keptReviewRowCount, 1), 25);
2880
+ const sample = await getTableRowsMinimal(campaignTableId, {
2881
+ limit: sampleLimit,
2882
+ page: 1,
2883
+ });
2884
+ reviewSampleRows = Array.isArray(sample?.rows) ? sample.rows : [];
2885
+ }
2886
+ catch {
2887
+ reviewSampleRows = [];
2888
+ }
2889
+ }
2715
2890
  // Persist currentStep if the caller asked for it. Do NOT touch
2716
2891
  // selectedLeadListId here: the campaign table id is already saved to
2717
2892
  // CampaignOffer.workflowTableId by /api/v3/campaign-builder/import-leads,
@@ -2729,6 +2904,7 @@ export async function confirmLeadList(input) {
2729
2904
  watchNarration: buildFilterChoiceWatchNarration({
2730
2905
  sourceLeadCount: leadListRowCount,
2731
2906
  reviewRowCount: keptReviewRowCount,
2907
+ sampleRows: reviewSampleRows,
2732
2908
  }),
2733
2909
  }
2734
2910
  : {}),
@@ -2771,8 +2947,8 @@ export async function confirmLeadList(input) {
2771
2947
  : undefined,
2772
2948
  message: requestedTargetLeadCount !== null &&
2773
2949
  leadListRowCount > requestedTargetLeadCount
2774
- ? `I found ${leadListRowCount} source candidates and imported the first ${Math.min(importedRowCount, requestedTargetLeadCount)} into the campaign review table. The watched campaign is now on filter-choice with sample assessment copy; announce Message Draft Builder background start only if that branch has actually started.`
2775
- : "Lead list imported into the campaign review table. The watched campaign is now on filter-choice with sample assessment copy; announce Message Draft Builder background start only if that branch has actually started.",
2950
+ ? `I found ${leadListRowCount} source candidates and imported the first ${Math.min(importedRowCount, requestedTargetLeadCount)} into the campaign review table. The watched campaign is now on filter-choice; ask add filters vs skip filters before loading post-lead workers.`
2951
+ : "Lead list imported into the campaign review table. The watched campaign is now on filter-choice; ask add filters vs skip filters before loading post-lead workers.",
2776
2952
  };
2777
2953
  }
2778
2954
  export function getProviderPrompt(input) {
@@ -339,9 +339,9 @@ export function getPostFindLeadsScoutRegistry() {
339
339
  reusePolicy: "The first completed Message Draft Builder recommendation remains the default review candidate. Later Lead Fit Builder, Filter Leads, enrichment, or rubric completion may make an enriched rewrite available, but does not automatically retry or replace the initial draft unless campaign/brief/source/list/table/review-batch identity mismatches or the initial output failed.",
340
340
  },
341
341
  usage: {
342
- codex: "After confirm_lead_list imports a non-empty bounded review batch and get_rows_minimal proves rows for workflowTableId, spawn the returned Message Draft Builder scout name immediately when the current Codex host exposes that custom agent. Spawn the Lead Fit Builder scout only after the user chooses filters.",
343
- claude: "After confirm_lead_list imports a non-empty bounded review batch and get_rows_minimal proves rows for workflowTableId, invoke the returned Message Draft Builder Task/Agent immediately when the current Claude session lists that agent. Invoke the Lead Fit Builder only after the user chooses filters.",
344
- parentThreadRule: "Named agents are optional acceleration. If they are absent, do not customer-surface install status; the main thread still orchestrates filter and message branches from CampaignOffer state, selected source state, workflowTableId, and imported review-batch rows. Local markdown/json files are not normal-path inputs. The message drafting branch starts at the review-batch import gate, must load the full generate-messages prompt, must read live campaign/review-batch state through scoped MCP/product tools, and must reject mismatched selectedLeadListId/workflowTableId/campaign/workspace input. Template approval in the parent thread uses the create-campaign-v2 message-review safety gate; do not load the full generate-messages prompt for approval-only review. Join before message review. Do not automatically rerun Message Draft Builder after filters/enrichment finish; show the initial draft by default and offer an enriched rewrite only with explicit user opt-in.",
342
+ codex: "After confirm_lead_list imports a non-empty bounded review batch and get_rows_minimal proves rows for workflowTableId, ask the filter-choice question immediately. Do not spawn returned post-lead scout names before that question. Once the user answers, spawn Message Draft Builder from the same campaign/table basis. If the user chooses filters, also spawn Lead Fit Builder, move to Filter Rules, save rubrics, then keep the browser on Filter Leads while the message recommendation is reviewed. If filters are skipped, move to Messages/message review.",
343
+ claude: "After confirm_lead_list imports a non-empty bounded review batch and get_rows_minimal proves rows for workflowTableId, ask the filter-choice question immediately. Do not invoke returned post-lead Task/Agent names before that question. Once the user answers, invoke Message Draft Builder from the same campaign/table basis. If the user chooses filters, also invoke Lead Fit Builder, move to Filter Rules, save rubrics, then keep the browser on Filter Leads while the message recommendation is reviewed. If filters are skipped, move to Messages/message review.",
344
+ parentThreadRule: "Named agents are optional acceleration. If they are absent, do not customer-surface install status; the main thread still orchestrates filter and message branches from CampaignOffer state, selected source state, workflowTableId, and imported review-batch rows. Local markdown/json files are not normal-path inputs. The filter-choice question is the first post-import user gate; do not load post-lead registries, filter references, or the full generate-messages prompt before it. Message drafting starts after the filter-choice answer, must load the full generate-messages prompt, must read live campaign/review-batch state through scoped MCP/product tools, and must reject mismatched selectedLeadListId/workflowTableId/campaign/workspace input. On the filter path, keep the browser on Filter Rules then Filter Leads while the background message agent prepares the template; enrichment/filtering/Generate Message cells wait for template approval. On the skip path, move to Messages/message review and wait for template approval before enrichment. Template approval in the parent thread uses the create-campaign-v2 message-review safety gate; do not load the full generate-messages prompt for approval-only review. Do not automatically rerun Message Draft Builder after filters/enrichment finish; show the initial draft by default and offer an enriched rewrite only with explicit user opt-in.",
345
345
  },
346
346
  };
347
347
  }
@@ -101,7 +101,7 @@ export const readinessToolDefinitions = [
101
101
  },
102
102
  targetLeadCount: {
103
103
  type: "number",
104
- description: "Target number of leads requested. Used as a fallback completion check when status is unavailable.",
104
+ description: "Target number of leads requested. Used as a fallback completion check when status is unavailable. For Signal Discovery, pass the approved source-candidate target; if the completed source list lands below it, the tool returns source_under_capacity instead of ready.",
105
105
  },
106
106
  timeoutMs: {
107
107
  type: "number",
@@ -472,6 +472,23 @@ export async function waitForLeadListReady(input) {
472
472
  }
473
473
  }
474
474
  if ((!requireRows || rowCount > 0) && importComplete) {
475
+ if (provider === "signal-discovery" &&
476
+ typeof targetLeadCount === "number" &&
477
+ rowCount > 0 &&
478
+ rowCount < targetLeadCount) {
479
+ return {
480
+ ready: false,
481
+ reason: "source_under_capacity",
482
+ leadListId,
483
+ provider: provider ?? null,
484
+ attempts,
485
+ elapsedMs: Date.now() - start,
486
+ rowCount,
487
+ status: lastStatus,
488
+ targetLeadCount,
489
+ error: `Signal Discovery completed with ${rowCount.toLocaleString("en-US")} source candidates, below the approved ${targetLeadCount.toLocaleString("en-US")} source-candidate target. Do not confirm this lead list; select more posts, rerun source discovery, or move to Sales Nav/Prospeo.`,
490
+ };
491
+ }
475
492
  return {
476
493
  ready: true,
477
494
  leadListId,
@@ -2557,10 +2557,11 @@ export declare const allTools: ({
2557
2557
  items: {
2558
2558
  type: string;
2559
2559
  };
2560
+ description: string;
2560
2561
  };
2561
2562
  company_job_posting_quantity: {
2562
- type: string;
2563
2563
  description: string;
2564
+ type: string;
2564
2565
  properties: {
2565
2566
  min: {
2566
2567
  type: string;
@@ -120,6 +120,7 @@ export declare function getTableRows(tableId: string, options?: {
120
120
  export declare function getTableRowsMinimal(tableId: string, options?: {
121
121
  limit?: number;
122
122
  page?: number;
123
+ includeMessages?: boolean;
123
124
  }): Promise<RowsResponse>;
124
125
  export declare function getRows(tableId: string, rowIds: string | string[]): Promise<{
125
126
  rows: FullRow[];
@@ -90,6 +90,8 @@ export async function getTableRowsMinimal(tableId, options) {
90
90
  if (options?.page)
91
91
  params.set("page", String(options.page));
92
92
  params.set("minimal", "1");
93
+ if (options?.includeMessages)
94
+ params.set("includeMessages", "1");
93
95
  const queryString = params.toString();
94
96
  const path = `/api/v3/mcp/tables/${tableId}/rows${queryString ? `?${queryString}` : ""}`;
95
97
  return api.get(path);
@@ -731,6 +731,7 @@ export async function waitForRubricResults(input) {
731
731
  if (minMessagesCount !== null && minPassFloorMet && rawMessageFloorMet) {
732
732
  rowSnapshotForMessageCheck = await getTableRowsMinimal(tableId, {
733
733
  limit: effectiveTarget,
734
+ includeMessages: true,
734
735
  });
735
736
  passingGeneratedMessagesCount = countPassingGeneratedMessages(rowSnapshotForMessageCheck);
736
737
  }
@@ -847,6 +848,7 @@ export async function waitForRubricResults(input) {
847
848
  try {
848
849
  timeoutPassingGeneratedMessagesCount = countPassingGeneratedMessages(await getTableRowsMinimal(tableId, {
849
850
  limit: effectiveTarget,
851
+ includeMessages: true,
850
852
  }));
851
853
  }
852
854
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.148",
3
+ "version": "0.1.150",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",