@sellable/mcp 0.1.274 → 0.1.275

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
@@ -16,9 +16,10 @@ 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 four public Sellable entrypoints shared across hosts:
19
+ There are five public Sellable entrypoints shared across hosts:
20
20
 
21
21
  - `sellable:create-campaign`
22
+ - `sellable:create-ab-test`
22
23
  - `sellable:foundation`
23
24
  - `sellable:content`
24
25
  - `sellable:create-post`
@@ -29,6 +30,11 @@ the approval-gated workflow from:
29
30
 
30
31
  - `mcp/sellable/skills/create-campaign-v2/SKILL.md`
31
32
 
33
+ The create-ab-test public wrapper prepares clean A/B split lead lists and
34
+ review-copy campaigns from:
35
+
36
+ - `mcp/sellable/skills/create-ab-test/SKILL.md`
37
+
32
38
  The foundation public wrapper loads the core identity/company memory workflow
33
39
  from:
34
40
 
@@ -148,8 +154,9 @@ The installer does the full local setup:
148
154
  `mcp__sellable__*` tools into skill sessions
149
155
 
150
156
  After the installer passes, fully quit and reopen Codex Desktop. Start a new
151
- thread and select `Sellable Create Campaign`, `Sellable Foundation`,
152
- `Sellable Content`, or `Sellable Create Post`; or invoke `$sellable:create-campaign`,
157
+ thread and select `Sellable Create Campaign`, `Sellable Create A/B Test`,
158
+ `Sellable Foundation`, `Sellable Content`, or `Sellable Create Post`; or invoke
159
+ `$sellable:create-campaign`, `$sellable:create-ab-test`,
153
160
  `$sellable:foundation`, `$sellable:content`, or `$sellable:create-post`. If the app still says
154
161
  `mcp__sellable__*` tools are missing after the installer passes, check that
155
162
  `~/.codex/config.toml` contains both `[marketplaces.sellable]` and
@@ -162,19 +169,23 @@ the Sellable MCP tools for Codex Desktop.
162
169
  Use these names consistently:
163
170
 
164
171
  - Claude Code command: `/sellable:create-campaign`
172
+ - Claude Code command: `/sellable:create-ab-test`
165
173
  - Claude Code command: `/sellable:foundation`
166
174
  - Claude Code command: `/sellable:content`
167
175
  - Claude Code command: `/sellable:create-post`
168
176
  - Codex command: `$sellable:create-campaign`
177
+ - Codex command: `$sellable:create-ab-test`
169
178
  - Codex command: `$sellable:foundation`
170
179
  - Codex command: `$sellable:content`
171
180
  - Codex command: `$sellable:create-post`
172
181
  - Codex Desktop plugin: `sellable@sellable`
173
182
  - Codex visible skill: `Sellable Create Campaign`
183
+ - Codex visible skill: `Sellable Create A/B Test`
174
184
  - Codex visible skill: `Sellable Foundation`
175
185
  - Codex visible skill: `Sellable Content`
176
186
  - Codex visible skill: `Sellable Create Post`
177
187
  - Codex skill frontmatter name: `create-campaign`
188
+ - Codex skill frontmatter name: `create-ab-test`
178
189
  - Codex skill frontmatter name: `foundation`
179
190
  - Codex skill frontmatter name: `content`
180
191
  - Codex skill frontmatter name: `create-post`
package/dist/server.js CHANGED
@@ -7,6 +7,7 @@ import { handleAddColumn, handleCommitBlueprint, } from "./tools/blueprint-commi
7
7
  import { bootstrapCreateCampaign } from "./tools/bootstrap.js";
8
8
  import { getCampaignTableSchema, queueCampaignCells, reviseMessageTemplateAndRerun, selectCampaignCells, waitForCampaignProcessing, } from "./tools/campaign-processing.js";
9
9
  import { createCampaign, duplicateCampaign, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
10
+ import { prepareCampaignAbTest } from "./tools/campaign-ab-test.js";
10
11
  import { queueCells, updateCell } from "./tools/cells.js";
11
12
  import { handleStartCliLogin, handleWaitForCliLogin, } from "./tools/cli-login.js";
12
13
  import { calculateLinkedInHookPreviewTool, capturePostIdeaTool, getPostDraftTool, getPostIdeaTool, getPublishedPostTool, listPostDraftIterationsTool, listPostDraftsTool, listPostIdeasTool, listPublishedPostsTool, markPostPublishedTool, renderLinkedInPostPreviewTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
@@ -215,6 +216,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
215
216
  markCampaignContextDirty(result.campaignOfferId, "duplicate_campaign");
216
217
  }
217
218
  break;
219
+ case "prepare_campaign_ab_test":
220
+ result = await prepareCampaignAbTest(args);
221
+ if (result?.variants?.A?.campaignId) {
222
+ markCampaignContextDirty(result.variants.A.campaignId, "prepare_campaign_ab_test");
223
+ }
224
+ if (result?.variants?.B?.campaignId) {
225
+ markCampaignContextDirty(result.variants.B.campaignId, "prepare_campaign_ab_test");
226
+ }
227
+ break;
218
228
  case "update_campaign_brief":
219
229
  result = await updateCampaignBrief(args?.campaignId, args?.campaignBrief);
220
230
  if (args?.campaignId) {
@@ -0,0 +1,72 @@
1
+ export interface PrepareCampaignAbTestInput {
2
+ sourceCampaignId: string;
3
+ variantName: string;
4
+ variantBriefDelta: string;
5
+ variantALabel?: string;
6
+ variantABriefDelta?: string;
7
+ splitStrategy?: "alternating_stable_sort";
8
+ targetCounts?: {
9
+ a?: number;
10
+ b?: number;
11
+ };
12
+ dryRun?: boolean;
13
+ idempotencyKey?: string;
14
+ }
15
+ export declare const campaignAbTestToolDefinitions: {
16
+ name: string;
17
+ description: string;
18
+ inputSchema: {
19
+ type: string;
20
+ properties: {
21
+ sourceCampaignId: {
22
+ type: string;
23
+ description: string;
24
+ };
25
+ variantName: {
26
+ type: string;
27
+ description: string;
28
+ };
29
+ variantBriefDelta: {
30
+ type: string;
31
+ description: string;
32
+ };
33
+ variantALabel: {
34
+ type: string;
35
+ description: string;
36
+ };
37
+ variantABriefDelta: {
38
+ type: string;
39
+ description: string;
40
+ };
41
+ splitStrategy: {
42
+ type: string;
43
+ enum: string[];
44
+ description: string;
45
+ };
46
+ targetCounts: {
47
+ type: string;
48
+ properties: {
49
+ a: {
50
+ type: string;
51
+ };
52
+ b: {
53
+ type: string;
54
+ };
55
+ };
56
+ additionalProperties: boolean;
57
+ description: string;
58
+ };
59
+ dryRun: {
60
+ type: string;
61
+ description: string;
62
+ };
63
+ idempotencyKey: {
64
+ type: string;
65
+ description: string;
66
+ };
67
+ };
68
+ required: string[];
69
+ additionalProperties: boolean;
70
+ };
71
+ }[];
72
+ export declare function prepareCampaignAbTest(input: PrepareCampaignAbTestInput): Promise<unknown>;
@@ -0,0 +1,60 @@
1
+ import { getApi } from "../api.js";
2
+ export const campaignAbTestToolDefinitions = [
3
+ {
4
+ name: "prepare_campaign_ab_test",
5
+ description: "Prepare a campaign A/B test from an existing campaign using its clean source lead list. Creates deterministic A/B split lead lists and review-copy campaigns, updates the variant brief, and never launches either campaign.",
6
+ inputSchema: {
7
+ type: "object",
8
+ properties: {
9
+ sourceCampaignId: {
10
+ type: "string",
11
+ description: "Existing source campaign ID.",
12
+ },
13
+ variantName: {
14
+ type: "string",
15
+ description: "Short label for variant B, e.g. New CTA.",
16
+ },
17
+ variantBriefDelta: {
18
+ type: "string",
19
+ description: "The specific campaign brief/copy change for variant B.",
20
+ },
21
+ variantALabel: {
22
+ type: "string",
23
+ description: "Optional label for the control variant.",
24
+ },
25
+ variantABriefDelta: {
26
+ type: "string",
27
+ description: "Optional explicit brief change for variant A. Omit for unchanged control.",
28
+ },
29
+ splitStrategy: {
30
+ type: "string",
31
+ enum: ["alternating_stable_sort"],
32
+ description: "Deterministic split strategy. Defaults to alternating_stable_sort.",
33
+ },
34
+ targetCounts: {
35
+ type: "object",
36
+ properties: {
37
+ a: { type: "number" },
38
+ b: { type: "number" },
39
+ },
40
+ additionalProperties: false,
41
+ description: "Optional exact split counts. Omit for an even alternating split.",
42
+ },
43
+ dryRun: {
44
+ type: "boolean",
45
+ description: "When true, returns split plan and review names without creating campaigns/lists.",
46
+ },
47
+ idempotencyKey: {
48
+ type: "string",
49
+ description: "Optional stable key for retrying the same A/B preparation safely.",
50
+ },
51
+ },
52
+ required: ["sourceCampaignId", "variantName", "variantBriefDelta"],
53
+ additionalProperties: false,
54
+ },
55
+ },
56
+ ];
57
+ export async function prepareCampaignAbTest(input) {
58
+ const api = getApi();
59
+ return api.post("/api/v3/mcp/campaign-ab-test", input);
60
+ }
@@ -9,6 +9,18 @@ export type CsvLinkedinLimits = {
9
9
  maxRows: number;
10
10
  maxCarryColumns: number;
11
11
  };
12
+ export type CsvLinkedinIgnoredReservedColumn = {
13
+ originalHeader: string;
14
+ canonicalName: string;
15
+ reason: string;
16
+ };
17
+ export type CsvLinkedinContentFingerprint = {
18
+ fileSha256: string;
19
+ sanitizedHeaderSha256: string;
20
+ selectedColumnsSha256: string;
21
+ ignoredReservedColumnsSha256: string;
22
+ rowCount: number;
23
+ };
12
24
  export type CsvLinkedinPreview = {
13
25
  resolvedFilePath: string;
14
26
  suggestedLeadListName: string;
@@ -26,6 +38,8 @@ export type CsvLinkedinPreview = {
26
38
  duplicateLinkedInCount: number;
27
39
  emptyLinkedInRowCount: number;
28
40
  duplicatePolicy: "first_wins";
41
+ ignoredReservedColumns: CsvLinkedinIgnoredReservedColumn[];
42
+ reservedColumnWarning: string | null;
29
43
  limits: CsvLinkedinLimits;
30
44
  warnings: string[];
31
45
  blockingErrors: string[];
@@ -38,6 +52,8 @@ export type CsvLinkedinConfirmationPayload = {
38
52
  fileMtimeMs: number;
39
53
  linkedInColumn: string;
40
54
  selectedColumns: string[];
55
+ ignoredReservedColumns: CsvLinkedinIgnoredReservedColumn[];
56
+ contentFingerprint: CsvLinkedinContentFingerprint;
41
57
  };
42
58
  export type PreparedLinkedinLeadRow = {
43
59
  row: Record<string, string>;
@@ -76,6 +92,9 @@ type ParsedCsvLinkedinFile = {
76
92
  headers: string[];
77
93
  rows: Array<Record<string, string>>;
78
94
  totalRows: number;
95
+ ignoredReservedColumns: CsvLinkedinIgnoredReservedColumn[];
96
+ reservedColumnWarning: string | null;
97
+ fileSha256: string;
79
98
  blockingErrors: string[];
80
99
  warnings: string[];
81
100
  };
@@ -86,6 +105,10 @@ export type LinkedInValidationResult = {
86
105
  valid: false;
87
106
  reason: string;
88
107
  };
108
+ export declare function getReservedWorkflowColumnMatch(header: string): {
109
+ canonicalName: string;
110
+ reason: string;
111
+ } | null;
89
112
  export declare function validateAndNormalizeLinkedInUrl(value: string): LinkedInValidationResult;
90
113
  export declare function makeLinkedinConfirmationToken(payload: CsvLinkedinConfirmationPayload): string;
91
114
  export declare function parseLinkedinConfirmationToken(token: string): CsvLinkedinConfirmationPayload;
@@ -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,
@@ -135,7 +135,7 @@ export interface PostFindLeadsScoutRegistryResponse {
135
135
  }
136
136
  export declare const DEFAULT_SUBSKILL_PROMPT_CHUNK_CHARS = 48000;
137
137
  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"];
138
+ 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
139
  export declare const promptToolDefinitions: ({
140
140
  name: string;
141
141
  description: string;
@@ -179,7 +179,7 @@ export declare const promptToolDefinitions: ({
179
179
  properties: {
180
180
  subskillName: {
181
181
  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"];
182
+ 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
183
  description: string;
184
184
  };
185
185
  offset: {
@@ -214,7 +214,7 @@ export declare const promptToolDefinitions: ({
214
214
  properties: {
215
215
  subskillName: {
216
216
  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"];
217
+ 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
218
  description: string;
219
219
  };
220
220
  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",
@@ -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: {
@@ -1140,6 +1140,62 @@ export declare const allTools: ({
1140
1140
  required: string[];
1141
1141
  additionalProperties?: undefined;
1142
1142
  };
1143
+ } | {
1144
+ name: string;
1145
+ description: string;
1146
+ inputSchema: {
1147
+ type: string;
1148
+ properties: {
1149
+ sourceCampaignId: {
1150
+ type: string;
1151
+ description: string;
1152
+ };
1153
+ variantName: {
1154
+ type: string;
1155
+ description: string;
1156
+ };
1157
+ variantBriefDelta: {
1158
+ type: string;
1159
+ description: string;
1160
+ };
1161
+ variantALabel: {
1162
+ type: string;
1163
+ description: string;
1164
+ };
1165
+ variantABriefDelta: {
1166
+ type: string;
1167
+ description: string;
1168
+ };
1169
+ splitStrategy: {
1170
+ type: string;
1171
+ enum: string[];
1172
+ description: string;
1173
+ };
1174
+ targetCounts: {
1175
+ type: string;
1176
+ properties: {
1177
+ a: {
1178
+ type: string;
1179
+ };
1180
+ b: {
1181
+ type: string;
1182
+ };
1183
+ };
1184
+ additionalProperties: boolean;
1185
+ description: string;
1186
+ };
1187
+ dryRun: {
1188
+ type: string;
1189
+ description: string;
1190
+ };
1191
+ idempotencyKey: {
1192
+ type: string;
1193
+ description: string;
1194
+ };
1195
+ };
1196
+ required: string[];
1197
+ additionalProperties: boolean;
1198
+ };
1143
1199
  } | {
1144
1200
  name: string;
1145
1201
  description: string;
@@ -2,6 +2,7 @@ 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";
5
6
  import { campaignProcessingToolDefinitions } from "./campaign-processing.js";
6
7
  import { cellToolDefinitions } from "./cells.js";
7
8
  import { startCliLoginToolDef, waitForCliLoginToolDef } from "./cli-login.js";
@@ -31,6 +32,7 @@ import { verifyRowToolDefinitions } from "./verify-row.js";
31
32
  import { workspaceToolDefinitions } from "./workspaces.js";
32
33
  export const allTools = [
33
34
  ...campaignToolDefinitions,
35
+ ...campaignAbTestToolDefinitions,
34
36
  ...campaignProcessingToolDefinitions,
35
37
  ...authToolDefinitions,
36
38
  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.275",
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
@@ -109,6 +109,22 @@ page before importing, call `list_dnc_entries`. Confirm the active workspace
109
109
  name and ID in the response before any write. This is Sellable's workspace DNC
110
110
  list used by DNC Check.
111
111
 
112
+ ## A/B Campaign Requests
113
+
114
+ If the user explicitly asks to create an A/B test, split an existing campaign
115
+ into variants, duplicate a campaign for copy testing, or compare two campaign
116
+ message approaches from the same source list, route them to
117
+ `create-ab-test` instead of improvising inside this create-campaign workflow.
118
+
119
+ Workflow/campaign table exports are decorated outputs for operator review and
120
+ debugging. They are not source lead lists for A/B splitting or campaign
121
+ duplication. Do not use `export_table_csv` from an enriched/generated campaign
122
+ table and then reimport it as leads. If the user only has a contaminated CSV,
123
+ `load_csv_linkedin_leads` strips Sellable workflow columns such as `ICP Score`,
124
+ `Passes Rubric`, `Generate Message`, `Message`, and `Approved`, but the
125
+ preferred A/B path is the dedicated `create-ab-test` workflow using
126
+ `prepare_campaign_ab_test`.
127
+
112
128
  ## Opening Turn Contract
113
129
 
114
130
  On the first visible response after this skill is invoked, do not narrate