@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 +14 -3
- package/dist/server.js +10 -0
- package/dist/tools/campaign-ab-test.d.ts +72 -0
- package/dist/tools/campaign-ab-test.js +60 -0
- package/dist/tools/csv-linkedin.d.ts +23 -0
- package/dist/tools/csv-linkedin.js +167 -7
- package/dist/tools/leads.d.ts +6 -0
- package/dist/tools/leads.js +8 -0
- package/dist/tools/prompts.d.ts +3 -3
- package/dist/tools/prompts.js +1 -0
- package/dist/tools/registry.d.ts +58 -2
- package/dist/tools/registry.js +2 -0
- package/package.json +1 -1
- package/skills/create-ab-test/SKILL.md +77 -0
- package/skills/create-campaign/SKILL.md +16 -0
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
|
|
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
|
|
152
|
-
`Sellable Content`, or `Sellable Create Post`; or invoke
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
package/dist/tools/leads.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/leads.js
CHANGED
|
@@ -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,
|
package/dist/tools/prompts.d.ts
CHANGED
|
@@ -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: {
|
package/dist/tools/prompts.js
CHANGED
package/dist/tools/registry.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/registry.js
CHANGED
|
@@ -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
|
@@ -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
|