@sellable/mcp 0.1.143 → 0.1.145

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.
Files changed (34) hide show
  1. package/README.md +19 -6
  2. package/agents/post-find-leads-message-scout.md +44 -0
  3. package/agents/registry.json +2 -2
  4. package/agents/source-scout-linkedin-engagement.md +4 -3
  5. package/agents/source-scout-prospeo-contact.md +1 -1
  6. package/agents/source-scout-sales-nav.md +3 -2
  7. package/dist/index-dev.js +0 -0
  8. package/dist/index.js +0 -0
  9. package/dist/tools/leads.js +168 -14
  10. package/dist/tools/processing.d.ts +1 -0
  11. package/dist/tools/prompts.js +3 -3
  12. package/dist/tools/rubrics.js +14 -9
  13. package/package.json +1 -1
  14. package/skills/create-campaign/SKILL.md +55 -34
  15. package/skills/create-campaign-v2/SKILL.md +59 -9
  16. package/skills/create-campaign-v2/SOUL.md +20 -12
  17. package/skills/create-campaign-v2/core/auto-execute.README.md +4 -4
  18. package/skills/create-campaign-v2/core/auto-execute.yaml +4 -4
  19. package/skills/create-campaign-v2/core/flow.v2.json +55 -19
  20. package/skills/create-campaign-v2/references/approval-gate-framing.md +1 -1
  21. package/skills/create-campaign-v2/references/filter-leads.md +42 -0
  22. package/skills/create-campaign-v2/references/final-handoff-contract.md +5 -5
  23. package/skills/create-campaign-v2/references/message-review-safety-gate.md +88 -13
  24. package/skills/create-campaign-v2/references/sample-validation-loop.md +18 -15
  25. package/skills/create-campaign-v2/references/step-13-import-leads.md +9 -6
  26. package/skills/create-campaign-v2/references/step-15-re-cascade.md +2 -3
  27. package/skills/create-campaign-v2/references/watch-guide-narration.md +39 -24
  28. package/skills/create-campaign-v2/references/watch-link-handoff.md +1 -1
  29. package/skills/create-campaign-v2-tail/SKILL.md +26 -13
  30. package/skills/load-voice/SKILL.md +129 -0
  31. package/skills/providers/prospeo.md +2 -1
  32. package/skills/providers/sales-nav.md +4 -2
  33. package/skills/providers/signal-discovery.md +12 -10
  34. package/skills/research/config.json +9 -0
package/README.md CHANGED
@@ -16,10 +16,11 @@ Each message gets 5+ minutes of Claude attention with deep research - no other t
16
16
 
17
17
  ### Prompt Source Of Truth
18
18
 
19
- There are two public Sellable entrypoints shared across hosts:
19
+ There are three public Sellable entrypoints shared across hosts:
20
20
 
21
21
  - `sellable:create-campaign`
22
22
  - `sellable:interview`
23
+ - `sellable:load-voice`
23
24
 
24
25
  The create-campaign public wrapper at
25
26
  `mcp/sellable/skills/create-campaign/SKILL.md` handles auth/bootstrap and loads
@@ -32,6 +33,11 @@ from:
32
33
 
33
34
  - `mcp/sellable/skills/interview/SKILL.md`
34
35
 
36
+ The load-voice public wrapper loads the current voice/company memory for use in
37
+ writing, application answers, posts, replies, and reviews from:
38
+
39
+ - `mcp/sellable/skills/load-voice/SKILL.md`
40
+
35
41
  Keep `create-campaign-v2` internal. Do not advertise it as a public command.
36
42
 
37
43
  ### 1. One-Command Agent Install
@@ -127,8 +133,9 @@ The installer does the full local setup:
127
133
  `mcp__sellable__*` tools into skill sessions
128
134
 
129
135
  After the installer passes, fully quit and reopen Codex Desktop. Start a new
130
- thread and select `Sellable Create Campaign` / `Sellable Identity Interview`, or
131
- invoke `$sellable:create-campaign` / `$sellable:interview`. If the app still says
136
+ thread and select `Sellable Create Campaign`, `Sellable Identity Interview`, or
137
+ `Sellable Load Voice`; or invoke `$sellable:create-campaign`,
138
+ `$sellable:interview`, or `$sellable:load-voice`. If the app still says
132
139
  `mcp__sellable__*` tools are missing after the installer passes, check that
133
140
  `~/.codex/config.toml` contains both `[marketplaces.sellable]` and
134
141
  `[plugins."sellable@sellable"]`.
@@ -141,13 +148,17 @@ Use these names consistently:
141
148
 
142
149
  - Claude Code command: `/sellable:create-campaign`
143
150
  - Claude Code command: `/sellable:interview`
151
+ - Claude Code command: `/sellable:load-voice`
144
152
  - Codex command: `$sellable:create-campaign`
145
153
  - Codex command: `$sellable:interview`
154
+ - Codex command: `$sellable:load-voice`
146
155
  - Codex Desktop plugin: `sellable@sellable`
147
156
  - Codex visible skill: `Sellable Create Campaign`
148
157
  - Codex visible skill: `Sellable Identity Interview`
158
+ - Codex visible skill: `Sellable Load Voice`
149
159
  - Codex skill frontmatter name: `create-campaign`
150
160
  - Codex skill frontmatter name: `interview`
161
+ - Codex skill frontmatter name: `load-voice`
151
162
  - MCP server name: `sellable`
152
163
  - Internal workflow prompt: `create-campaign-v2`
153
164
 
@@ -255,9 +266,11 @@ Parallel execution contract:
255
266
  through `get_source_scout_registry`. Add new scouts there first; installer,
256
267
  Codex config, Claude agent files, and prompts should consume the registry.
257
268
  - Post-find-leads scout names also come from `agents/registry.json` and are
258
- exposed through `get_post_find_leads_scout_registry`. After source approval,
259
- use that registry to launch the filter-leads scout and message-generation
260
- scout together when the host supports real subagents.
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.
261
274
  - Claude host: use the installed `source-scout-linkedin-engagement`,
262
275
  `source-scout-sales-nav`, and `source-scout-prospeo-contact` Task/Agent
263
276
  subagents for parallel lead-source scouting only when the current session
@@ -136,3 +136,47 @@ Return a concise status with:
136
136
  - one rendered omit/fallback sample
137
137
  - quality-gate pass/fail summary
138
138
  - whether final template review is ready or needs revision
139
+
140
+ When the parent will show the recommendation in chat, format the customer-facing
141
+ message review as Markdown with distinct copy blocks:
142
+
143
+ ````markdown
144
+ ## Message Template
145
+
146
+ **Subject**
147
+
148
+ ```text
149
+ {{tokenized_subject}}
150
+ ```
151
+
152
+ **Body**
153
+
154
+ ```text
155
+ {{tokenized_message_body}}
156
+ ```
157
+
158
+ ## Rendered Examples
159
+
160
+ ### Good token fill
161
+
162
+ ```text
163
+ Subject: ...
164
+
165
+ Hey First,
166
+
167
+ ...
168
+ ```
169
+
170
+ ### Good omit / fallback
171
+
172
+ ```text
173
+ Subject: ...
174
+
175
+ Hey First,
176
+
177
+ ...
178
+ ```
179
+ ````
180
+
181
+ Use a table for token rules. Keep explanations outside the fenced `text` blocks
182
+ so the user can quickly see exactly what copy is being approved.
@@ -168,7 +168,7 @@
168
168
  "optionalProducesArtifacts": [],
169
169
  "ownership": "lead quality, false-positive patterns, keep/exclude rules, ability-to-pay checks, and production rubric translation only",
170
170
  "codex": {
171
- "description": "Lead Fit Builder for campaign-backed lead filtering and rubric persistence after source approval.",
171
+ "description": "Lead Fit Builder for campaign-backed lead filtering and rubric persistence after review-batch import and filter approval.",
172
172
  "model": "gpt-5.5",
173
173
  "modelReasoningEffort": "high",
174
174
  "sandboxMode": "read-only",
@@ -179,7 +179,7 @@
179
179
  ]
180
180
  },
181
181
  "claude": {
182
- "description": "Use proactively as Lead Fit Builder after lead source approval to persist campaign rubrics from campaign state.",
182
+ "description": "Use proactively as Lead Fit Builder after review-batch import and filter approval to persist campaign rubrics from campaign state.",
183
183
  "model": "inherit",
184
184
  "background": true,
185
185
  "maxTurns": 8,
@@ -51,7 +51,8 @@ currentStep: "signal-discovery" })` before sampling so the watched Signal
51
51
  visible headline/display-name cues only. Do not enrich people during
52
52
  viability estimation.
53
53
  7. Compute capacity before recommending the source: source target good-fit
54
- leads (default 300 unless the parent supplies a target), reachable engagers,
54
+ leads (default 150 for Signal Discovery unless the parent supplies a target),
55
+ reachable engagers,
55
56
  sampled ICP-fit rate as `n/N` plus an easy percentage/range, expected usable
56
57
  leads per 100 engagers before and after a conservative dedupe/cleanup
57
58
  factor, required engagers to scrape (`source target / fit rate`), average
@@ -93,8 +94,8 @@ Evidence standards:
93
94
  exist, how many sampled engagers looked in-ICP, how many good-fit prospects
94
95
  that implies per 100 engagers, how many usable prospects one right-content
95
96
  post should yield after cleanup, how many engagers must be scraped for the
96
- 300-good-fit source target, how many posts are needed for that source target,
97
+ 150-good-fit source target, how many posts are needed for that source target,
97
98
  and which posts you would use. Also say the first campaign import remains the
98
- bounded 15-row review batch.
99
+ bounded review batch.
99
100
  - If `fetch_post_engagers` is unavailable or fails, report that explicitly and mark the estimate lower-confidence.
100
101
  - Keep LinkedIn Engagement viable when selected posts can produce roughly 150+ ICP-fit warm prospects before final filtering, even if Sales Nav is more scalable.
@@ -27,7 +27,7 @@ Process:
27
27
  1. Read the campaign brief, source intake, kickoff doc, or lane prompt supplied by the parent.
28
28
  2. Identify whether this is domain/account 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.
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.
31
31
  5. Call out that Prospeo gives contact/account coverage but usually weaker LinkedIn intent than LinkedIn Engagement or Sales Nav activity slices.
32
32
 
33
33
  Return a concise structured result with:
@@ -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
- unless the parent supplies a target). If raw preview volume or projected usable volume
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).
33
+ If raw preview volume or projected usable volume
33
34
  is below target, do not present the tiny result as the scale fallback yet.
34
35
  Loosen nonessential filters in order: remove recent-activity first, widen
35
36
  adjacent title variants, widen geography/company-size constraints, and only
package/dist/index-dev.js CHANGED
File without changes
package/dist/index.js CHANGED
File without changes
@@ -14,10 +14,11 @@ const workspaceRoot = resolveWorkspaceRoot(entryDir);
14
14
  const skillsRoot = resolveSkillsDir();
15
15
  const signalProviderConfigPath = join(skillsRoot, "create-campaign/core/providers/signal-discovery.json");
16
16
  const leadImportLimitsPath = join(workspaceRoot, "lead-import-limits.json");
17
+ const campaignSourceDefaultsPath = join(workspaceRoot, "campaign-source-defaults.json");
17
18
  const defaultLeadImportLimits = {
18
19
  apollo: { maxImportCount: 2500 },
19
20
  "sales-nav": { maxImportCount: 2500 },
20
- prospeo: { maxImportCount: 10000 },
21
+ prospeo: { maxImportCount: 2500 },
21
22
  "signal-discovery": { maxImportCount: 2500 },
22
23
  };
23
24
  const defaultSignalDiscoveryConfig = {
@@ -25,6 +26,28 @@ const defaultSignalDiscoveryConfig = {
25
26
  promisingPostsTarget: 3,
26
27
  },
27
28
  };
29
+ const defaultCampaignSourceDefaults = {
30
+ reviewBatch: {
31
+ defaultSize: 25,
32
+ minProjectedPass: 5,
33
+ },
34
+ providers: {
35
+ "signal-discovery": {
36
+ targetGoodFitLeads: 150,
37
+ defaultFitRate: 0.15,
38
+ maxSourceCandidates: 2500,
39
+ postCoverageBuffer: 1.2,
40
+ },
41
+ "sales-nav": {
42
+ targetGoodFitLeads: 300,
43
+ maxSourceCandidates: 2500,
44
+ },
45
+ prospeo: {
46
+ targetGoodFitLeads: 300,
47
+ maxSourceCandidates: 2500,
48
+ },
49
+ },
50
+ };
28
51
  const prospeoFilterValueSchema = {
29
52
  type: "object",
30
53
  description: "Include/exclude list filter (values must match Prospeo enums)",
@@ -245,6 +268,39 @@ function loadLeadImportLimits() {
245
268
  return defaultLeadImportLimits;
246
269
  }
247
270
  }
271
+ function loadCampaignSourceDefaults() {
272
+ if (!existsSync(campaignSourceDefaultsPath)) {
273
+ return defaultCampaignSourceDefaults;
274
+ }
275
+ try {
276
+ const raw = readFileSync(campaignSourceDefaultsPath, "utf-8");
277
+ const parsed = JSON.parse(raw);
278
+ return {
279
+ reviewBatch: {
280
+ ...defaultCampaignSourceDefaults.reviewBatch,
281
+ ...(parsed.reviewBatch ?? {}),
282
+ },
283
+ providers: {
284
+ ...defaultCampaignSourceDefaults.providers,
285
+ ...(parsed.providers ?? {}),
286
+ },
287
+ };
288
+ }
289
+ catch {
290
+ return defaultCampaignSourceDefaults;
291
+ }
292
+ }
293
+ function getSignalDiscoverySourcePlanDefaults() {
294
+ const defaults = loadCampaignSourceDefaults();
295
+ const signalDefaults = defaults.providers["signal-discovery"];
296
+ const sourceCandidateTarget = Math.min(Math.ceil(signalDefaults.targetGoodFitLeads / signalDefaults.defaultFitRate), signalDefaults.maxSourceCandidates);
297
+ return {
298
+ targetGoodFitLeads: signalDefaults.targetGoodFitLeads,
299
+ defaultFitRate: signalDefaults.defaultFitRate,
300
+ sourceCandidateTarget,
301
+ reviewBatchSize: defaults.reviewBatch.defaultSize,
302
+ };
303
+ }
248
304
  function getMaxImportCount(provider) {
249
305
  const limits = loadLeadImportLimits();
250
306
  const max = limits[provider]?.maxImportCount;
@@ -443,17 +499,17 @@ function buildSourceImportWatchNarration({ provider, selectedPostCount, estimate
443
499
  : ""}`
444
500
  : `the approved ${providerLabel} source`;
445
501
  const targetDetail = typeof targetLeadCount === "number"
446
- ? ` Targeting ${targetLeadCount.toLocaleString("en-US")} source leads before the bounded review batch is cloned.`
502
+ ? ` Targeting ${targetLeadCount.toLocaleString("en-US")} source candidates before the bounded review batch is cloned.`
447
503
  : "";
448
504
  return {
449
505
  stage: "review-batch",
450
506
  headline: provider === "signal-discovery"
451
- ? "Scraping source leads from posts"
452
- : "Importing source leads",
507
+ ? "Scraping source candidates from posts"
508
+ : "Importing source candidates",
453
509
  visibleState: `The browser is showing ${providerLabel} import progress for ${sourceDetail}.${targetDetail}`,
454
510
  agentIntent: "Codex is materializing the approved source into a lead list before cloning only the bounded review batch into the campaign.",
455
- nextAction: "Wait for source leads, then import the 15-row review batch",
456
- safety: "No enrichment, sequence, or sending starts during source import.",
511
+ nextAction: "Wait for source candidates, then import the review batch",
512
+ safety: "Import is limited to the review batch.",
457
513
  };
458
514
  }
459
515
  function buildSourceImportRecoveryWatchNarration(args) {
@@ -481,7 +537,7 @@ function buildSourceImportRecoveryWatchNarration(args) {
481
537
  nextAction: args.reason === "failed" || args.reason === "zero"
482
538
  ? "Retry the import or change the approved source"
483
539
  : "Wait again, retry readiness, or change the source",
484
- safety: "No enrichment, filtering, message cells, sequence, or sending has started.",
540
+ safety: "The campaign is still at source recovery.",
485
541
  };
486
542
  }
487
543
  function buildFilterChoiceWatchNarration({ sourceLeadCount, reviewRowCount, }) {
@@ -497,7 +553,7 @@ function buildFilterChoiceWatchNarration({ sourceLeadCount, reviewRowCount, }) {
497
553
  visibleState: `${reviewCopy} The browser is showing the filter-choice screen and sample rows.`,
498
554
  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.",
499
555
  nextAction: "Choose filters or skip",
500
- safety: "No enrichment, filtering, Generate Message cells, sequence, or sending starts from this choice.",
556
+ safety: "This choice sets the filter path.",
501
557
  workerStatuses: {
502
558
  leadFitBuilder: "idle",
503
559
  messageDraftBuilder: "idle",
@@ -511,7 +567,7 @@ function buildSignalDiscoverySearchWatchNarration() {
511
567
  visibleState: "The browser is showing Signal Discovery while LinkedIn post searches run.",
512
568
  agentIntent: "Codex is searching the approved post themes and will ask before scraping selected engagers into a source list.",
513
569
  nextAction: "Review selected posts before scraping",
514
- safety: "Search only stores post candidates; it does not import leads, enrich, generate messages, create a sequence, or send.",
570
+ safety: "Scrape approval is the next gate.",
515
571
  };
516
572
  }
517
573
  function buildSignalDiscoveryResultsWatchNarration(postsReturned) {
@@ -524,7 +580,7 @@ function buildSignalDiscoveryResultsWatchNarration(postsReturned) {
524
580
  visibleState: postCopy,
525
581
  agentIntent: "Codex is promoting the strongest posts, then it will ask before scraping the selected engager pool.",
526
582
  nextAction: "Approve selected posts before scraping",
527
- safety: "Post results do not import leads, enrich, generate messages, create a sequence, or send.",
583
+ safety: "Scrape approval is the next gate.",
528
584
  };
529
585
  }
530
586
  function buildSelectedPostApprovalWatchNarration(selectedPostCount) {
@@ -533,10 +589,59 @@ function buildSelectedPostApprovalWatchNarration(selectedPostCount) {
533
589
  headline: "Approve selected-post scrape",
534
590
  visibleState: `${selectedPostCount.toLocaleString("en-US")} LinkedIn post${selectedPostCount === 1 ? "" : "s"} selected in Signal Discovery.`,
535
591
  agentIntent: "Codex is asking before scraping this selected engager pool into a source list and cloning only the bounded review batch into the campaign.",
536
- nextAction: `Scrape ${selectedPostCount.toLocaleString("en-US")} selected post${selectedPostCount === 1 ? "" : "s"} for the bounded review batch`,
537
- safety: "Post selection does not import leads, enrich, generate messages, create a sequence, or send.",
592
+ nextAction: `Approve scraping ${selectedPostCount.toLocaleString("en-US")} Signal Discovery post${selectedPostCount === 1 ? "" : "s"}`,
593
+ safety: "Scrape approval is the next gate.",
538
594
  };
539
595
  }
596
+ function escapeMarkdownTableCell(value) {
597
+ return (value || "-").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
598
+ }
599
+ function formatApproxInteger(value) {
600
+ const rounded = value >= 100 ? Math.round(value / 10) * 10 : Math.round(value);
601
+ return `~${rounded.toLocaleString("en-US")}`;
602
+ }
603
+ function buildSignalDiscoverySourceRecommendation({ selectedPosts, }) {
604
+ const { targetGoodFitLeads, defaultFitRate, sourceCandidateTarget, reviewBatchSize, } = getSignalDiscoverySourcePlanDefaults();
605
+ const selectedCount = selectedPosts.length;
606
+ const totalEngagement = selectedPosts.reduce((sum, post) => sum + (post.likes ?? 0) + (post.comments ?? 0), 0);
607
+ const tableRows = selectedPosts
608
+ .map((post) => {
609
+ const engagement = (post.likes ?? 0) + (post.comments ?? 0);
610
+ return `| ${escapeMarkdownTableCell(post.authorName)} | ${escapeMarkdownTableCell(post.reason)} | ${formatApproxInteger(engagement)} |`;
611
+ })
612
+ .join("\n");
613
+ const estimatedGoodFit = totalEngagement * defaultFitRate;
614
+ return `## Source Recommendation
615
+
616
+ Use Signal Discovery first.
617
+
618
+ **Good-fit target:** ~${targetGoodFitLeads.toLocaleString("en-US")} prospects after cleanup, enrichment, and filters<br>
619
+ **Source-candidate plan:** scrape ~${sourceCandidateTarget.toLocaleString("en-US")} raw engagers using a conservative ${Math.round(defaultFitRate * 100)}% fit-rate assumption<br>
620
+ **Review checkpoint:** import the first ${reviewBatchSize.toLocaleString("en-US")} leads into the campaign for fit and message review before scaling
621
+
622
+ ### Selected posts
623
+
624
+ | Post | Why it fits | Visible engagement |
625
+ |---|---|---:|
626
+ ${tableRows || "| Selected posts | Campaign-matched public engagement | - |"}
627
+
628
+ **Total visible pool:** ${formatApproxInteger(totalEngagement)} engagers<br>
629
+ **Estimated good-fit pool at ${Math.round(defaultFitRate * 100)}%:** ${formatApproxInteger(estimatedGoodFit)} prospects before dedupe/risk cleanup
630
+
631
+ ### Recommendation
632
+
633
+ Approve scraping these ${selectedCount} posts.
634
+
635
+ This gives enough volume to work toward ~${targetGoodFitLeads.toLocaleString("en-US")} good-fit prospects while keeping the source tied to people already engaging with the campaign's strongest public buying signals.
636
+
637
+ **First pass:** build the source list, then import only the ${reviewBatchSize.toLocaleString("en-US")}-lead review batch so we can inspect fit and messages before scaling.
638
+
639
+ **Fallback:** if the review batch is too vendor-heavy, agency-heavy, or off-ICP, switch to Sales Nav recent activity.
640
+
641
+ Approval card should say:
642
+
643
+ **Approve scraping ${selectedCount} Signal Discovery post${selectedCount === 1 ? "" : "s"}?**`;
644
+ }
540
645
  function normalizeImportProvider(provider) {
541
646
  if (provider === "apollo-ai" || provider === "apollo")
542
647
  return "apollo";
@@ -1117,7 +1222,7 @@ export const leadToolDefinitions = [
1117
1222
  },
1118
1223
  targetEngagerCount: {
1119
1224
  type: "number",
1120
- description: "Signal Discovery: target number of post engagers to scrape. Compute from source target good-fit leads / sampled fit rate; e.g. 300 good fits at 20% fit requires about 1500 engagers. Limits selected posts before starting scrape.",
1225
+ description: "Signal Discovery: target number of post engagers/source candidates to scrape. Compute from source target good-fit leads / sampled fit rate; e.g. 150 good fits at 15% fit requires about 1000 engagers. Limits selected posts before starting scrape.",
1121
1226
  },
1122
1227
  maxPostsToScrape: {
1123
1228
  type: "number",
@@ -2726,12 +2831,61 @@ export async function selectPromisingPosts(input) {
2726
2831
  currentStep: "signal-discovery",
2727
2832
  watchNarration: buildSelectedPostApprovalWatchNarration(selectionResult.selectedCount),
2728
2833
  });
2834
+ let sourceRecommendation = "";
2835
+ try {
2836
+ const tabsResponse = await api.get(`/api/v3/campaigns/${campaignOfferId}/signal-discovery/tabs`);
2837
+ const reasonsByPostId = new Map(selections.map((selection) => [selection.postId, selection.reason]));
2838
+ const selectedByUrl = new Map();
2839
+ for (const tab of tabsResponse.tabs ?? []) {
2840
+ for (const post of tab.posts ?? []) {
2841
+ if (!post.isSelected)
2842
+ continue;
2843
+ const urlKey = normalizePostUrl(post.postUrl ?? undefined) ||
2844
+ post.postUrl ||
2845
+ post.id ||
2846
+ "";
2847
+ if (!urlKey)
2848
+ continue;
2849
+ const directReason = post.id ? reasonsByPostId.get(post.id) : undefined;
2850
+ const urlReason = post.postUrl && reasonsByPostId.get(post.postUrl)
2851
+ ? reasonsByPostId.get(post.postUrl)
2852
+ : undefined;
2853
+ selectedByUrl.set(urlKey, {
2854
+ authorName: post.authorName,
2855
+ reason: directReason ||
2856
+ urlReason ||
2857
+ (post.postContent ? truncate(post.postContent, 70) : null),
2858
+ likes: post.likes,
2859
+ comments: post.comments,
2860
+ });
2861
+ }
2862
+ }
2863
+ sourceRecommendation = buildSignalDiscoverySourceRecommendation({
2864
+ selectedPosts: Array.from(selectedByUrl.values()),
2865
+ });
2866
+ }
2867
+ catch {
2868
+ const { reviewBatchSize } = getSignalDiscoverySourcePlanDefaults();
2869
+ sourceRecommendation = `## Source Recommendation
2870
+
2871
+ Use Signal Discovery first.
2872
+
2873
+ **Recommendation:** approve scraping the ${selectionResult.selectedCount} selected Signal Discovery post${selectionResult.selectedCount === 1 ? "" : "s"}.
2874
+
2875
+ **First pass:** build the source list, then import only the ${reviewBatchSize.toLocaleString("en-US")}-lead review batch so we can inspect fit and messages before scaling.
2876
+
2877
+ Approval card should say:
2878
+
2879
+ **Approve scraping ${selectionResult.selectedCount} Signal Discovery post${selectionResult.selectedCount === 1 ? "" : "s"}?**`;
2880
+ }
2729
2881
  return {
2730
2882
  success: true,
2731
2883
  selectedCount: selectionResult.selectedCount,
2732
2884
  unselectedCount: selectionResult.unselectedCount,
2733
2885
  criteriaCount: selectionResult.criteriaCount,
2734
- message: `Selected ${selectionResult.selectedCount} posts with ${selectionResult.criteriaCount} ICP criteria (persisted). Criteria are already saved to the campaign. Ask the user to approve scraping these selected posts before calling import_leads; the earlier source-plan approval only authorized search/scouting.`,
2886
+ message: `${sourceRecommendation}
2887
+
2888
+ Selected ${selectionResult.selectedCount} posts with ${selectionResult.criteriaCount} ICP criteria persisted. Ask the user to approve the specific scraping action before calling import_leads; the earlier source-plan approval only authorized search/scouting.`,
2735
2889
  };
2736
2890
  }
2737
2891
  export async function setHeadlineICPCriteria(input) {
@@ -60,6 +60,7 @@ export type CampaignRubricsResponse = {
60
60
  enableICPFilters: boolean | null;
61
61
  currentStep?: string | null;
62
62
  workflowTableId: string | null;
63
+ hasApprovedMessageTemplate?: boolean | null;
63
64
  rubrics: RubricItem[];
64
65
  };
65
66
  export declare function fetchCampaignRubrics(campaignOfferId: string): Promise<CampaignRubricsResponse>;
@@ -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 the user approves or auto-confirms the lead source, spawn both returned scout `name` values in one assistant turn only when the current Codex host exposes those custom agents.",
343
- claude: "After lead source approval, invoke both returned Task/Agent subagents in one assistant message only when the current Claude session lists those agents, so filter-leads and message generation run concurrently.",
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 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, 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.",
345
345
  },
346
346
  };
347
347
  }
@@ -86,21 +86,26 @@ async function fetchCampaignOffer(campaignOfferId) {
86
86
  workflowTableId: v3.workflowTableId,
87
87
  enableICPFilters: v3.enableICPFilters,
88
88
  currentStep: v3.currentStep,
89
+ hasApprovedMessageTemplate: v3.hasApprovedMessageTemplate,
89
90
  leadScoringRubrics: v3.rubrics,
90
91
  };
91
92
  }
92
93
  const filterLeadsReadyWatchNarration = {
93
94
  stage: "fit-message",
94
- headline: "Filter Leads is ready",
95
- visibleState: "Filters are enabled, rubrics are saved, and the browser is showing Filter Leads.",
96
- agentIntent: "Codex is waiting for the approved message template before running enrichment, filtering, or message cells.",
97
- nextAction: "Review or approve the message template before filtering",
95
+ headline: "Filter rules saved",
96
+ visibleState: "The browser is staying on Filter Leads with the fit rules saved.",
97
+ agentIntent: "Codex is preparing the message template for approval before enrichment, filtering, or message cells run.",
98
+ nextAction: "Review the message template in chat",
98
99
  progressLabel: "Fit + message",
99
- safety: "Saved rules do not start enrichment, filtering, Generate Message cells, sequence setup, or sending.",
100
+ safety: "Template approval comes before row execution.",
100
101
  };
101
- function shouldMoveToFilterLeads(currentStep) {
102
+ function shouldMoveToFilterLeads(campaign) {
103
+ const currentStep = campaign.currentStep;
102
104
  if (!currentStep)
103
105
  return true;
106
+ if (currentStep === "messages") {
107
+ return campaign.hasApprovedMessageTemplate === false;
108
+ }
104
109
  return [
105
110
  "confirm-lead-list",
106
111
  "filter-choice",
@@ -109,11 +114,11 @@ function shouldMoveToFilterLeads(currentStep) {
109
114
  "apply-icp-rubric",
110
115
  ].includes(currentStep);
111
116
  }
112
- function buildEnableIcpFiltersPayload(currentStep) {
117
+ function buildEnableIcpFiltersPayload(campaign) {
113
118
  const payload = {
114
119
  enableICPFilters: true,
115
120
  };
116
- if (shouldMoveToFilterLeads(currentStep)) {
121
+ if (shouldMoveToFilterLeads(campaign)) {
117
122
  payload.currentStep = "apply-icp-rubric";
118
123
  payload.watchNarration = filterLeadsReadyWatchNarration;
119
124
  }
@@ -568,7 +573,7 @@ export async function saveRubrics(input) {
568
573
  let currentStepSet = false;
569
574
  try {
570
575
  const api = getApi();
571
- const payload = buildEnableIcpFiltersPayload(campaign.currentStep);
576
+ const payload = buildEnableIcpFiltersPayload(campaign);
572
577
  await api.put(`/api/v2/campaign-offers/${input.campaignOfferId}`, payload);
573
578
  enableICPFiltersSet = true;
574
579
  currentStepSet = payload.currentStep === "apply-icp-rubric";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.143",
3
+ "version": "0.1.145",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",