@sellable/mcp 0.1.274 → 0.1.276

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.
@@ -1,5 +1,5 @@
1
1
  import { parse } from "csv-parse/sync";
2
- import { createHmac, randomBytes } from "node:crypto";
2
+ import { createHash, createHmac, randomBytes } from "node:crypto";
3
3
  import { readFileSync, statSync } from "node:fs";
4
4
  import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
5
5
  import { resolveWorkspaceRoot } from "../utils/workspace-root.js";
@@ -49,6 +49,86 @@ const CSV_LINKEDIN_LIMITS = {
49
49
  maxRows: MAX_CSV_LINKEDIN_UPLOAD_ROWS,
50
50
  maxCarryColumns: MAX_CSV_LINKEDIN_UPLOAD_CARRY_COLUMNS,
51
51
  };
52
+ const RESERVED_WORKFLOW_COLUMNS = new Map([
53
+ "DNC Check",
54
+ "Enable ICP Filtering",
55
+ "ICP Score",
56
+ "ICP Alignment Notes",
57
+ "Passes Rubric",
58
+ "Generate Message",
59
+ "Message",
60
+ "Subject",
61
+ "Approved",
62
+ "Enable Scheduling",
63
+ "Scheduled",
64
+ "Scheduled At",
65
+ "Scheduled For",
66
+ "Sent",
67
+ "Sent At",
68
+ "Send Status",
69
+ "Delivery Status",
70
+ "Reply Status",
71
+ "Connection Status",
72
+ "Invite Status",
73
+ "InMail Status",
74
+ "DM Status",
75
+ "Status",
76
+ "Result",
77
+ "Error",
78
+ "Last Error",
79
+ "Generated Message",
80
+ "Message Draft",
81
+ "Message Status",
82
+ "Approval Status",
83
+ "Queued At",
84
+ "Processing Status",
85
+ "Workflow Status",
86
+ "Cell Status",
87
+ ].map((name) => [normalizeReservedWorkflowHeaderKey(name), name]));
88
+ function normalizeReservedWorkflowHeaderKey(value) {
89
+ return value
90
+ .trim()
91
+ .replace(/\s*(?:\(\d+\)|[._-]\d+|\s+\d+)$/u, "")
92
+ .toLowerCase()
93
+ .replace(/[\s_.-]+/g, " ")
94
+ .replace(/[^a-z0-9 ]+/g, "")
95
+ .trim();
96
+ }
97
+ export function getReservedWorkflowColumnMatch(header) {
98
+ const key = normalizeReservedWorkflowHeaderKey(header);
99
+ if (!key) {
100
+ return null;
101
+ }
102
+ const canonicalName = RESERVED_WORKFLOW_COLUMNS.get(key);
103
+ if (!canonicalName) {
104
+ return null;
105
+ }
106
+ return {
107
+ canonicalName,
108
+ reason: "Sellable workflow/output columns are recreated by each campaign table and cannot be imported as lead source fields.",
109
+ };
110
+ }
111
+ function buildReservedColumnWarning(ignored) {
112
+ if (ignored.length === 0) {
113
+ return null;
114
+ }
115
+ const names = Array.from(new Set(ignored.map((column) => column.originalHeader)));
116
+ return `Detected campaign workflow columns and stripped them because Sellable recreates those columns in each campaign table: ${names.join(", ")}.`;
117
+ }
118
+ function hashJson(value) {
119
+ return createHash("sha256")
120
+ .update(JSON.stringify(value))
121
+ .digest("hex");
122
+ }
123
+ function buildContentFingerprint(params) {
124
+ return {
125
+ fileSha256: params.fileSha256,
126
+ sanitizedHeaderSha256: hashJson(params.headers),
127
+ selectedColumnsSha256: hashJson(params.selectedColumns),
128
+ ignoredReservedColumnsSha256: hashJson(params.ignoredReservedColumns),
129
+ rowCount: params.rowCount,
130
+ };
131
+ }
52
132
  function normalizeHeaderKey(value) {
53
133
  return value
54
134
  .trim()
@@ -61,6 +141,17 @@ function compareStringArrays(left, right) {
61
141
  }
62
142
  return left.every((value, index) => value === right[index]);
63
143
  }
144
+ function compareIgnoredReservedColumns(left, right) {
145
+ if (left.length !== right.length) {
146
+ return false;
147
+ }
148
+ return left.every((column, index) => {
149
+ const other = right[index];
150
+ return (column.originalHeader === other.originalHeader &&
151
+ column.canonicalName === other.canonicalName &&
152
+ column.reason === other.reason);
153
+ });
154
+ }
64
155
  function getLimits() {
65
156
  return { ...CSV_LINKEDIN_LIMITS };
66
157
  }
@@ -100,6 +191,30 @@ function buildRowObjects(headers, dataRows) {
100
191
  return out;
101
192
  });
102
193
  }
194
+ function stripReservedWorkflowColumns(headers, dataRows) {
195
+ const keepIndexes = [];
196
+ const ignoredReservedColumns = [];
197
+ headers.forEach((header, index) => {
198
+ const match = getReservedWorkflowColumnMatch(header);
199
+ if (!match) {
200
+ keepIndexes.push(index);
201
+ return;
202
+ }
203
+ ignoredReservedColumns.push({
204
+ originalHeader: header,
205
+ canonicalName: match.canonicalName,
206
+ reason: match.reason,
207
+ });
208
+ });
209
+ if (ignoredReservedColumns.length === 0) {
210
+ return { headers, dataRows, ignoredReservedColumns };
211
+ }
212
+ return {
213
+ headers: keepIndexes.map((index) => headers[index]),
214
+ dataRows: dataRows.map((row) => keepIndexes.map((index) => String(row[index] ?? ""))),
215
+ ignoredReservedColumns,
216
+ };
217
+ }
103
218
  function resolveHeaderSelection(headers, requested, kind) {
104
219
  const trimmed = requested.trim();
105
220
  if (!trimmed) {
@@ -516,7 +631,17 @@ export function matchesLinkedinConfirmationToken(token, payload) {
516
631
  parsed.fileSizeBytes === payload.fileSizeBytes &&
517
632
  parsed.fileMtimeMs === payload.fileMtimeMs &&
518
633
  parsed.linkedInColumn === payload.linkedInColumn &&
519
- compareStringArrays(parsed.selectedColumns, payload.selectedColumns));
634
+ compareStringArrays(parsed.selectedColumns, payload.selectedColumns) &&
635
+ compareIgnoredReservedColumns(parsed.ignoredReservedColumns, payload.ignoredReservedColumns) &&
636
+ parsed.contentFingerprint.fileSha256 ===
637
+ payload.contentFingerprint.fileSha256 &&
638
+ parsed.contentFingerprint.sanitizedHeaderSha256 ===
639
+ payload.contentFingerprint.sanitizedHeaderSha256 &&
640
+ parsed.contentFingerprint.selectedColumnsSha256 ===
641
+ payload.contentFingerprint.selectedColumnsSha256 &&
642
+ parsed.contentFingerprint.ignoredReservedColumnsSha256 ===
643
+ payload.contentFingerprint.ignoredReservedColumnsSha256 &&
644
+ parsed.contentFingerprint.rowCount === payload.contentFingerprint.rowCount);
520
645
  }
521
646
  export function parseCsvLinkedinFile(input) {
522
647
  const resolvedFilePath = resolveCsvLinkedinPath(input.filePath, input.workspaceRoot);
@@ -536,8 +661,9 @@ export function parseCsvLinkedinFile(input) {
536
661
  blockingErrors.push(`CSV file exceeds the ${MAX_CSV_LINKEDIN_UPLOAD_BYTES} byte limit.`);
537
662
  }
538
663
  let rawRows;
664
+ let content;
539
665
  try {
540
- const content = readFileSync(resolvedFilePath, "utf8");
666
+ content = readFileSync(resolvedFilePath, "utf8");
541
667
  rawRows = parse(content, {
542
668
  bom: true,
543
669
  columns: false,
@@ -559,6 +685,9 @@ export function parseCsvLinkedinFile(input) {
559
685
  headers: [],
560
686
  rows: [],
561
687
  totalRows: 0,
688
+ ignoredReservedColumns: [],
689
+ reservedColumnWarning: null,
690
+ fileSha256: "",
562
691
  blockingErrors,
563
692
  warnings,
564
693
  };
@@ -567,6 +696,14 @@ export function parseCsvLinkedinFile(input) {
567
696
  const dataRows = rawRows
568
697
  .slice(1)
569
698
  .map((row) => row.map((value) => String(value ?? "")));
699
+ const fileSha256 = createHash("sha256").update(content).digest("hex");
700
+ const stripped = stripReservedWorkflowColumns(headers, dataRows);
701
+ const sanitizedHeaders = stripped.headers;
702
+ const sanitizedDataRows = stripped.dataRows;
703
+ const reservedColumnWarning = buildReservedColumnWarning(stripped.ignoredReservedColumns);
704
+ if (reservedColumnWarning) {
705
+ warnings.push(reservedColumnWarning);
706
+ }
570
707
  if (headers.length === 0) {
571
708
  blockingErrors.push("CSV file must include at least one header column.");
572
709
  }
@@ -574,7 +711,10 @@ export function parseCsvLinkedinFile(input) {
574
711
  if (blankHeaderCount > 0) {
575
712
  blockingErrors.push("CSV header row contains blank column names.");
576
713
  }
577
- const duplicateHeaders = findDuplicateHeaders(headers);
714
+ if (sanitizedHeaders.length === 0 && headers.length > 0) {
715
+ blockingErrors.push("CSV looks like a campaign workflow export rather than a clean LinkedIn lead list; all usable columns were Sellable workflow/output columns.");
716
+ }
717
+ const duplicateHeaders = findDuplicateHeaders(sanitizedHeaders);
578
718
  if (duplicateHeaders.length > 0) {
579
719
  blockingErrors.push(`CSV header row contains duplicate columns: ${duplicateHeaders.join(", ")}`);
580
720
  }
@@ -585,7 +725,9 @@ export function parseCsvLinkedinFile(input) {
585
725
  if (dataRows.length > MAX_CSV_LINKEDIN_UPLOAD_ROWS) {
586
726
  blockingErrors.push(`CSV contains ${dataRows.length} data rows, which exceeds the ${MAX_CSV_LINKEDIN_UPLOAD_ROWS} row limit.`);
587
727
  }
588
- const rows = blockingErrors.length > 0 ? [] : buildRowObjects(headers, dataRows);
728
+ const rows = blockingErrors.length > 0
729
+ ? []
730
+ : buildRowObjects(sanitizedHeaders, sanitizedDataRows);
589
731
  if (rows.length === 0 && dataRows.length === 0) {
590
732
  warnings.push("CSV contains headers but no data rows.");
591
733
  }
@@ -594,9 +736,12 @@ export function parseCsvLinkedinFile(input) {
594
736
  suggestedLeadListName: buildLeadListNameSuggestion(resolvedFilePath),
595
737
  fileSizeBytes: stats.size,
596
738
  fileMtimeMs: stats.mtimeMs,
597
- headers,
739
+ headers: sanitizedHeaders,
598
740
  rows,
599
741
  totalRows: dataRows.length,
742
+ ignoredReservedColumns: stripped.ignoredReservedColumns,
743
+ reservedColumnWarning,
744
+ fileSha256,
600
745
  blockingErrors,
601
746
  warnings,
602
747
  };
@@ -752,8 +897,13 @@ export function buildCsvLinkedinPreview(input) {
752
897
  duplicateCount: 0,
753
898
  emptyCount: 0,
754
899
  };
900
+ if (!resolvedLinkedInColumn && parsed.ignoredReservedColumns.length > 0) {
901
+ blockingErrors.push("CSV looks like a campaign workflow export rather than a clean LinkedIn lead list; after stripping Sellable workflow/output columns, no LinkedIn profile URL column was available.");
902
+ }
755
903
  if (resolvedLinkedInColumn && prepared.rows.length === 0) {
756
- blockingErrors.push("CSV did not produce any valid LinkedIn profile URLs after validation.");
904
+ blockingErrors.push(parsed.ignoredReservedColumns.length > 0
905
+ ? "CSV looks like a campaign workflow export rather than a clean LinkedIn lead list; no valid LinkedIn profile URLs remained after stripping Sellable workflow/output columns."
906
+ : "CSV did not produce any valid LinkedIn profile URLs after validation.");
757
907
  }
758
908
  if (prepared.invalidCount > 0) {
759
909
  warnings.push(`Skipped ${prepared.invalidCount} invalid LinkedIn URL row(s).`);
@@ -772,6 +922,14 @@ export function buildCsvLinkedinPreview(input) {
772
922
  fileMtimeMs: parsed.fileMtimeMs,
773
923
  linkedInColumn: resolvedLinkedInColumn,
774
924
  selectedColumns,
925
+ ignoredReservedColumns: parsed.ignoredReservedColumns,
926
+ contentFingerprint: buildContentFingerprint({
927
+ fileSha256: parsed.fileSha256,
928
+ headers,
929
+ selectedColumns,
930
+ ignoredReservedColumns: parsed.ignoredReservedColumns,
931
+ rowCount: parsed.totalRows,
932
+ }),
775
933
  }
776
934
  : null;
777
935
  return {
@@ -792,6 +950,8 @@ export function buildCsvLinkedinPreview(input) {
792
950
  duplicateLinkedInCount: prepared.duplicateCount,
793
951
  emptyLinkedInRowCount: prepared.emptyCount,
794
952
  duplicatePolicy: "first_wins",
953
+ ignoredReservedColumns: parsed.ignoredReservedColumns,
954
+ reservedColumnWarning: parsed.reservedColumnWarning,
795
955
  limits: getLimits(),
796
956
  warnings,
797
957
  blockingErrors,
@@ -4151,6 +4151,8 @@ export declare function loadCsvLinkedinLeads(input: LoadCsvLinkedinLeadsInput):
4151
4151
  invalidCount: number;
4152
4152
  duplicateCount: number;
4153
4153
  emptyLinkedInRowCount: number;
4154
+ ignoredReservedColumns: import("./csv-linkedin.js").CsvLinkedinIgnoredReservedColumn[];
4155
+ reservedColumnWarning: string | null;
4154
4156
  duplicatePolicy: "first_wins";
4155
4157
  limits: import("./csv-linkedin.js").CsvLinkedinLimits;
4156
4158
  warnings: string[];
@@ -4186,6 +4188,8 @@ export declare function loadCsvLinkedinLeads(input: LoadCsvLinkedinLeadsInput):
4186
4188
  invalidCount?: undefined;
4187
4189
  duplicateCount?: undefined;
4188
4190
  emptyLinkedInRowCount?: undefined;
4191
+ ignoredReservedColumns?: undefined;
4192
+ reservedColumnWarning?: undefined;
4189
4193
  duplicatePolicy?: undefined;
4190
4194
  limits?: undefined;
4191
4195
  warnings?: undefined;
@@ -4225,6 +4229,8 @@ export declare function loadCsvLinkedinLeads(input: LoadCsvLinkedinLeadsInput):
4225
4229
  invalidCount?: undefined;
4226
4230
  duplicateCount?: undefined;
4227
4231
  emptyLinkedInRowCount?: undefined;
4232
+ ignoredReservedColumns?: undefined;
4233
+ reservedColumnWarning?: undefined;
4228
4234
  duplicatePolicy?: undefined;
4229
4235
  limits?: undefined;
4230
4236
  warnings?: undefined;
@@ -2348,6 +2348,7 @@ function normalizeLinkedinConfirmationPayload(payload) {
2348
2348
  return {
2349
2349
  ...payload,
2350
2350
  selectedColumns: [...payload.selectedColumns],
2351
+ ignoredReservedColumns: [...(payload.ignoredReservedColumns ?? [])],
2351
2352
  };
2352
2353
  }
2353
2354
  function compareStringArrays(left, right) {
@@ -3367,6 +3368,11 @@ export async function loadCsvLinkedinLeads(input) {
3367
3368
  fileMtimeMs: previewBuild.fileMtimeMs,
3368
3369
  linkedInColumn: previewBuild.resolvedLinkedInColumn ?? "",
3369
3370
  selectedColumns: previewBuild.preview.selectedColumns,
3371
+ ignoredReservedColumns: previewBuild.preview.ignoredReservedColumns,
3372
+ contentFingerprint: previewBuild.preview.confirmationToken
3373
+ ? parseLinkedinConfirmationToken(previewBuild.preview.confirmationToken)
3374
+ .contentFingerprint
3375
+ : tokenPayload.contentFingerprint,
3370
3376
  };
3371
3377
  if (!matchesLinkedinConfirmationToken(input.confirmationToken, currentPayload)) {
3372
3378
  throw new Error("CSV file changed after preview. Re-run load_csv_linkedin_leads preview before confirming.");
@@ -3480,6 +3486,8 @@ export async function loadCsvLinkedinLeads(input) {
3480
3486
  invalidCount: previewBuild.preview.invalidLinkedInRowCount,
3481
3487
  duplicateCount: previewBuild.preview.duplicateLinkedInCount,
3482
3488
  emptyLinkedInRowCount: previewBuild.preview.emptyLinkedInRowCount,
3489
+ ignoredReservedColumns: previewBuild.preview.ignoredReservedColumns,
3490
+ reservedColumnWarning: previewBuild.preview.reservedColumnWarning,
3483
3491
  duplicatePolicy: previewBuild.preview.duplicatePolicy,
3484
3492
  limits: previewBuild.preview.limits,
3485
3493
  warnings: previewBuild.preview.warnings,
@@ -82,6 +82,7 @@ export interface SourceScoutRegistryResponse {
82
82
  codex: string;
83
83
  claude: string;
84
84
  parentThreadRule: string;
85
+ prepareMessagesRule?: string;
85
86
  };
86
87
  }
87
88
  export interface PostFindLeadsScoutRegistryResponse {
@@ -131,11 +132,12 @@ export interface PostFindLeadsScoutRegistryResponse {
131
132
  codex: string;
132
133
  claude: string;
133
134
  parentThreadRule: string;
135
+ prepareMessagesRule?: string;
134
136
  };
135
137
  }
136
138
  export declare const DEFAULT_SUBSKILL_PROMPT_CHUNK_CHARS = 48000;
137
139
  export declare const MAX_SUBSKILL_PROMPT_CHUNK_CHARS = 48000;
138
- export declare const ALLOWED_SUBSKILL_PROMPT_NAMES: readonly ["building-gtm-tables", "content", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
140
+ export declare const ALLOWED_SUBSKILL_PROMPT_NAMES: readonly ["building-gtm-tables", "content", "create-ab-test", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
139
141
  export declare const promptToolDefinitions: ({
140
142
  name: string;
141
143
  description: string;
@@ -179,7 +181,7 @@ export declare const promptToolDefinitions: ({
179
181
  properties: {
180
182
  subskillName: {
181
183
  type: string;
182
- enum: readonly ["building-gtm-tables", "content", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
184
+ enum: readonly ["building-gtm-tables", "content", "create-ab-test", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
183
185
  description: string;
184
186
  };
185
187
  offset: {
@@ -214,7 +216,7 @@ export declare const promptToolDefinitions: ({
214
216
  properties: {
215
217
  subskillName: {
216
218
  type: string;
217
- enum: readonly ["building-gtm-tables", "content", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
219
+ enum: readonly ["building-gtm-tables", "content", "create-ab-test", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
218
220
  description: string;
219
221
  };
220
222
  assetPath: {
@@ -19,6 +19,7 @@ const DEPRECATED_SUBSKILL_PROMPT_REPLACEMENTS = {
19
19
  export const ALLOWED_SUBSKILL_PROMPT_NAMES = [
20
20
  "building-gtm-tables",
21
21
  "content",
22
+ "create-ab-test",
22
23
  "create-campaign",
23
24
  "create-campaign-brief",
24
25
  "create-campaign-v2",
@@ -365,6 +366,7 @@ export function getPostFindLeadsScoutRegistry() {
365
366
  codex: "After confirm_lead_list copies source rows and the initial campaign-table execution slice exists, ask the filter-choice question immediately. Do not spawn anything before that question. After the answer, launch only Message Drafting whenever Codex agent-launch policy is satisfied. The registry lookup is not a launch: after get_post_find_leads_scout_registry, immediately invoke Task/spawn_agent or the host background-agent mechanism before loading filter-leads.md or saving rubrics. If filters are chosen, the parent stays on Filter Rules and drafts/saves rubrics with MCP tools while Message Drafting runs in the background. If filters are skipped, move to Messages/message review after Message Drafting is ready. Treat YOLO/autonomous mode as campaign-scoped permission for this single post-import worker; do not ask for another permission click in YOLO. If the user has not enabled YOLO and has not explicitly asked for background agents/subagents/parallel agents/delegation/message bg agent in this campaign, ask once before loading the long message prompt in the parent. If permission is granted and the named Message Drafting custom agent is unavailable, spawn a generic gpt-5.5 xhigh Message Drafting background agent with the same lean campaign/table basis. If no background-agent tool is callable, start the same full message branch inline before filter drafting, or return blocked/retry-needed; do not wait until filters are saved and then call the registry.",
366
367
  claude: "After confirm_lead_list copies source rows and the initial campaign-table execution slice exists, ask the filter-choice question immediately. Do not invoke any Task/Agent before that question. After the answer, invoke only Message Drafting. If filters are chosen, parent drafts/saves rubrics with MCP tools while Message Drafting runs, asks filter approval, then joins Message Drafting. If filters are skipped, invoke only Message Drafting and move to Messages/message review.",
367
368
  parentThreadRule: 'Named agents are optional acceleration, but message drafting is not optional. The only normal background worker is Message Drafting. YOLO/autonomous mode counts as campaign-scoped permission for this single post-import worker; do not ask for another permission click in YOLO. If a named agent is unavailable after permission, use a generic gpt-5.5 xhigh Message Drafting background agent. source work and filter work stay in the parent thread with MCP tools. If post-find-leads-message-scout is available, run it as the background Message Draft Builder after the filter-choice answer. The registry lookup is not a launch: get_post_find_leads_scout_registry only identifies the worker, and Message Drafting counts as started only after Task/spawn_agent or the host background-agent tool is invoked, or after the parent begins the same full message branch inline because no background-agent tool is callable. This launch must happen before loading filter-leads.md, save_rubrics, or filter approval. If post-find-leads-message-scout is absent, do not customer-surface install status. Do not silently fall back to parent-thread message drafting; the main thread must execute the same message branch from CampaignOffer state, selected source state, workflowTableId, and initial campaign-table execution slice rows. If no background-agent tool is callable, start that same full message branch inline before filter drafting, or return blocked/retry-needed; do not wait until filters are saved and then call the registry. The Message Drafting handoff must be lean. Do not paste copied row counts, brief hashes, review-batch hashes, full reviewBatchRowIds, broad row data, or local debug artifacts into the spawn prompt. 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 or filter references before it. Message drafting starts after the filter-choice answer, must load get_subskill_prompt({ subskillName: "generate-messages" }), and must load every required message asset named by generate-messages Mode 0 through get_subskill_asset before drafting. Reference Asset Loading means loading the required pre-draft reference pack before drafting; return blocked/retry-needed if required assets cannot be loaded; load ai-tells.md because it is never optional. The branch loads the full generate-messages prompt and every referenced asset through get_subskill_asset. After generating/revising the candidate and before returning ready, must load get_subskill_prompt({ subskillName: "create-campaign-v2-validation" }) as the final internal validation gate, must read live campaign table state through scoped MCP/product tools, and must reject mismatched selectedLeadListId/workflowTableId/campaign/workspace input. Do not block when filters were chosen but leadScoringRubrics are not yet visible in the branch read; the parent owns save_rubrics and filter approval in parallel, so Message Drafting should return status ready with basisStatus usable_initial when the campaign/list/table and non-empty execution slice match. Do not use any alternate, local-artifact, or examples-only message prompt. User copy feedback, message QA, or rewrite requests before approve-message must be routed back to Message Drafting with the current recommendation, lean campaign/table basis, and latest user text; the parent must not rewrite or QA the template from memory and must not call update_campaign_brief before approve-message. The worker validates internally and returns only templateRecommendation, tokenFillRules, renderedGoodSample, status, approveOrReviseRecommendation, validationStatus, outputAt, outputHash, and blocked/retry detail. Do not render renderedFallbackSample, risk notes, or a qaReceipt on the normal happy path. On the filter path, save_rubrics keeps the browser on Filter Rules after save_rubrics so the user can approve the saved criteria; only then move to Filter Leads, show `Filters saved + waiting for message approval`, and wait there for message approval. Enrichment, filtering, Generate Message cells, sender setup, sequence attach, and launch wait for template approval on the Use Template path. On the skip path, move to Messages/message review after Message Drafting is ready and wait for message approval before enrichment or Settings. Do not render message review from checklist or shortcut instructions; message review requires a messageDraftRecommendation whose basis proves the generate-messages prompt, required message assets, and validation gate ran for the current campaign/table execution slice. Do not automatically rerun Message Drafting after filters/enrichment finish; show the initial draft by default and offer an enriched rewrite only with explicit user opt-in. Handoff and recommendation output are Markdown with labeled fields, not raw JSON.',
369
+ prepareMessagesRule: 'After message approval, prefer start_prepare_campaign_messages for target-ready preparation such as prepare 100 messages checking up to 300 rows. campaignId is CampaignOffer.id. approvalMode defaults to mark_ready; approvalMode approve requires explicit user intent to flip Approved cells. Poll get_prepare_campaign_messages_status and summarize checked rows, prepared/approved count, target, rows remaining, and stop reason. If the user asks to stop preparation, the target is wrong, or status shows the wrong campaign/table, call cancel_prepare_campaign_messages; otherwise do not cancel a healthy prepare run. Low-level selectors are diagnostics and recovery only for this lane. start_campaign remains forbidden until final launch greenlight.',
368
370
  },
369
371
  };
370
372
  }
@@ -225,7 +225,7 @@ export declare const allTools: ({
225
225
  properties: {
226
226
  subskillName: {
227
227
  type: string;
228
- enum: readonly ["building-gtm-tables", "content", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
228
+ enum: readonly ["building-gtm-tables", "content", "create-ab-test", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
229
229
  description: string;
230
230
  };
231
231
  offset: {
@@ -260,7 +260,7 @@ export declare const allTools: ({
260
260
  properties: {
261
261
  subskillName: {
262
262
  type: string;
263
- enum: readonly ["building-gtm-tables", "content", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
263
+ enum: readonly ["building-gtm-tables", "content", "create-ab-test", "create-campaign", "create-campaign-brief", "create-campaign-v2", "create-campaign-v2-tail", "create-campaign-v2-validation", "create-post", "create-rubric", "engage", "enrich-prospects", "find-leads", "foundation", "generate-messages", "interview", "load-voice", "research", "research-prospect", "research-sender", "workflow-sequences"];
264
264
  description: string;
265
265
  };
266
266
  assetPath: {
@@ -659,6 +659,66 @@ export declare const allTools: ({
659
659
  required: string[];
660
660
  additionalProperties: boolean;
661
661
  };
662
+ } | {
663
+ name: string;
664
+ description: string;
665
+ inputSchema: {
666
+ type: string;
667
+ properties: {
668
+ campaignId: {
669
+ type: string;
670
+ description: string;
671
+ };
672
+ tableId: {
673
+ type: string;
674
+ };
675
+ targetPreparedMessages: {
676
+ type: string;
677
+ };
678
+ maxRowsToCheck: {
679
+ type: string;
680
+ };
681
+ batchSize: {
682
+ type: string;
683
+ };
684
+ approvalMode: {
685
+ type: string;
686
+ enum: string[];
687
+ description: string;
688
+ };
689
+ autoContinue: {
690
+ type: string;
691
+ };
692
+ jobId?: undefined;
693
+ };
694
+ required: string[];
695
+ additionalProperties: boolean;
696
+ };
697
+ } | {
698
+ name: string;
699
+ description: string;
700
+ inputSchema: {
701
+ type: string;
702
+ properties: {
703
+ jobId: {
704
+ type: string;
705
+ };
706
+ campaignId: {
707
+ type: string;
708
+ description?: undefined;
709
+ };
710
+ tableId: {
711
+ type: string;
712
+ };
713
+ targetPreparedMessages?: undefined;
714
+ maxRowsToCheck?: undefined;
715
+ batchSize?: undefined;
716
+ approvalMode?: undefined;
717
+ autoContinue?: undefined;
718
+ };
719
+ additionalProperties: boolean;
720
+ required?: undefined;
721
+ };
662
722
  } | {
663
723
  name: string;
664
724
  description: string;
@@ -1140,6 +1200,62 @@ export declare const allTools: ({
1140
1200
  required: string[];
1141
1201
  additionalProperties?: undefined;
1142
1202
  };
1203
+ } | {
1204
+ name: string;
1205
+ description: string;
1206
+ inputSchema: {
1207
+ type: string;
1208
+ properties: {
1209
+ sourceCampaignId: {
1210
+ type: string;
1211
+ description: string;
1212
+ };
1213
+ variantName: {
1214
+ type: string;
1215
+ description: string;
1216
+ };
1217
+ variantBriefDelta: {
1218
+ type: string;
1219
+ description: string;
1220
+ };
1221
+ variantALabel: {
1222
+ type: string;
1223
+ description: string;
1224
+ };
1225
+ variantABriefDelta: {
1226
+ type: string;
1227
+ description: string;
1228
+ };
1229
+ splitStrategy: {
1230
+ type: string;
1231
+ enum: string[];
1232
+ description: string;
1233
+ };
1234
+ targetCounts: {
1235
+ type: string;
1236
+ properties: {
1237
+ a: {
1238
+ type: string;
1239
+ };
1240
+ b: {
1241
+ type: string;
1242
+ };
1243
+ };
1244
+ additionalProperties: boolean;
1245
+ description: string;
1246
+ };
1247
+ dryRun: {
1248
+ type: string;
1249
+ description: string;
1250
+ };
1251
+ idempotencyKey: {
1252
+ type: string;
1253
+ description: string;
1254
+ };
1255
+ };
1256
+ required: string[];
1257
+ additionalProperties: boolean;
1258
+ };
1143
1259
  } | {
1144
1260
  name: string;
1145
1261
  description: string;
@@ -2,6 +2,8 @@ import { authToolDefinitions } from "./auth.js";
2
2
  import { blueprintCommitToolDefinitions } from "./blueprint-commit.js";
3
3
  import { bootstrapToolDefinitions } from "./bootstrap.js";
4
4
  import { campaignToolDefinitions } from "./campaigns.js";
5
+ import { campaignAbTestToolDefinitions } from "./campaign-ab-test.js";
6
+ import { campaignMessagePreparationToolDefinitions } from "./campaign-message-preparation.js";
5
7
  import { campaignProcessingToolDefinitions } from "./campaign-processing.js";
6
8
  import { cellToolDefinitions } from "./cells.js";
7
9
  import { startCliLoginToolDef, waitForCliLoginToolDef } from "./cli-login.js";
@@ -31,6 +33,8 @@ import { verifyRowToolDefinitions } from "./verify-row.js";
31
33
  import { workspaceToolDefinitions } from "./workspaces.js";
32
34
  export const allTools = [
33
35
  ...campaignToolDefinitions,
36
+ ...campaignAbTestToolDefinitions,
37
+ ...campaignMessagePreparationToolDefinitions,
34
38
  ...campaignProcessingToolDefinitions,
35
39
  ...authToolDefinitions,
36
40
  startCliLoginToolDef,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.274",
3
+ "version": "0.1.276",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: create-ab-test
3
+ description: Create a Sellable campaign A/B test from a clean source lead list.
4
+ visibility: public
5
+ allowed-tools:
6
+ - mcp__sellable__get_auth_status
7
+ - mcp__sellable__get_active_workspace
8
+ - mcp__sellable__get_campaign
9
+ - mcp__sellable__get_campaign_context
10
+ - mcp__sellable__get_campaign_navigation_state
11
+ - mcp__sellable__get_campaign_messages_preview
12
+ - mcp__sellable__prepare_campaign_ab_test
13
+ - mcp__sellable__load_csv_linkedin_leads
14
+ - mcp__sellable__wait_for_lead_list_ready
15
+ - mcp__sellable__get_table_rows
16
+ - mcp__sellable__get_rows_minimal
17
+ ---
18
+
19
+ # Sellable Create A/B Test
20
+
21
+ Use this workflow when the user asks to create an A/B campaign test, split a
22
+ campaign into variants, duplicate a campaign for copy testing, or compare two
23
+ campaign-message approaches from the same source list.
24
+
25
+ ## Opening Contract
26
+
27
+ Start by identifying the source campaign, the one variable being tested, and
28
+ the clean source lead origin. Then prepare the split and stop for review. The
29
+ goal is two review-copy campaigns with clean split source lists, not a launched
30
+ campaign.
31
+
32
+ ## Required Flow
33
+
34
+ 1. Confirm the source campaign ID or resolve it with `get_campaign`.
35
+ 2. Confirm the A/B variable in plain language. Keep variant B as the explicit
36
+ change and leave variant A as the control unless the user names an A change.
37
+ 3. Verify the campaign has a clean source lead list. A clean source list is a
38
+ lead-list table, not the generated campaign workflow table.
39
+ 4. Call `prepare_campaign_ab_test` with `dryRun: true` first.
40
+ 5. Review split counts, duplicate/skipped counts, campaign names, and variant
41
+ brief deltas with the user.
42
+ 6. Only after review, call `prepare_campaign_ab_test` without `dryRun` using the
43
+ same `idempotencyKey` if one was returned or supplied.
44
+ 7. Return the A and B campaign IDs, split lead-list IDs, counts, and the
45
+ explicit note that both campaigns are `not_started`.
46
+
47
+ ## Anti-Patterns
48
+
49
+ - Do not call `export_table_csv` from an enriched/generated campaign workflow
50
+ table as the lead source for A/B splitting.
51
+ - Do not reimport workflow output columns such as `ICP Score`, `Passes Rubric`,
52
+ `Generate Message`, `Message`, `Approved`, scheduling, status, result, or
53
+ error columns.
54
+ - Do not use broad workflow-table selectors to split decorated campaign rows.
55
+ - Do not call `start_campaign`, approve launch, attach senders for launch, or
56
+ trigger live sends in this workflow.
57
+
58
+ ## Clean Source Fallback
59
+
60
+ If the source campaign has no clean source lead list, or the source list is
61
+ polluted with workflow/output columns, ask for the original CSV or clean source
62
+ list. Use `load_csv_linkedin_leads`; it strips Sellable workflow columns if a
63
+ contaminated CSV is supplied, then creates a clean lead list. After the clean
64
+ source is confirmed, retry `prepare_campaign_ab_test`.
65
+
66
+ ## Output Contract
67
+
68
+ Report:
69
+
70
+ - source campaign ID
71
+ - source lead-list ID
72
+ - split strategy and counts
73
+ - duplicate/skipped lead counts
74
+ - A review campaign ID and split lead-list ID
75
+ - B review campaign ID and split lead-list ID
76
+ - variant difference
77
+ - `launchState: not_started` for both campaigns