@sellable/mcp 0.1.31 → 0.1.33

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/dist/auth.d.ts CHANGED
@@ -44,6 +44,31 @@ export declare function updateActiveWorkspace(params: {
44
44
  workspaceId: string;
45
45
  workspaceName?: string | null;
46
46
  }): void;
47
+ /**
48
+ * Write a fresh Sellable config to disk in the canonical flat shape.
49
+ *
50
+ * Used by the FTUX `wait_for_cli_login` MCP tool after a successful magic-link
51
+ * sign-in. Composes `getConfigPath()` + `writeRawConfigFile()` and writes the
52
+ * same on-disk shape that `packages/sellable-install/bin/sellable-install.mjs`
53
+ * `writeAuth(opts)` produces, and that the auth resolution chain in
54
+ * `getConfig()` reads.
55
+ *
56
+ * Field set is intentionally minimal — `{ token, activeWorkspaceId,
57
+ * activeWorkspaceName?, apiUrl }` — matching the legacy flat format. Do NOT
58
+ * introduce new fields here; that's a config-shape migration, not this helper's
59
+ * job.
60
+ *
61
+ * Because `DISABLE_CONFIG_CACHE = true`, the next `getConfig()` call sees the
62
+ * new config without an MCP server restart.
63
+ */
64
+ export declare function writeNewConfig(opts: {
65
+ token: string;
66
+ activeWorkspaceId: string;
67
+ activeWorkspaceName?: string;
68
+ apiUrl: string;
69
+ }): {
70
+ configPath: string;
71
+ };
47
72
  export declare function getEngageState(): {
48
73
  activeWorkspaceId: string | null;
49
74
  state: EngageWorkspaceState | null;
package/dist/auth.js CHANGED
@@ -178,6 +178,36 @@ function writeRawConfigFile(configPath, raw) {
178
178
  fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
179
179
  cachedConfig = null;
180
180
  }
181
+ /**
182
+ * Write a fresh Sellable config to disk in the canonical flat shape.
183
+ *
184
+ * Used by the FTUX `wait_for_cli_login` MCP tool after a successful magic-link
185
+ * sign-in. Composes `getConfigPath()` + `writeRawConfigFile()` and writes the
186
+ * same on-disk shape that `packages/sellable-install/bin/sellable-install.mjs`
187
+ * `writeAuth(opts)` produces, and that the auth resolution chain in
188
+ * `getConfig()` reads.
189
+ *
190
+ * Field set is intentionally minimal — `{ token, activeWorkspaceId,
191
+ * activeWorkspaceName?, apiUrl }` — matching the legacy flat format. Do NOT
192
+ * introduce new fields here; that's a config-shape migration, not this helper's
193
+ * job.
194
+ *
195
+ * Because `DISABLE_CONFIG_CACHE = true`, the next `getConfig()` call sees the
196
+ * new config without an MCP server restart.
197
+ */
198
+ export function writeNewConfig(opts) {
199
+ const configPath = getConfigPath();
200
+ const raw = {
201
+ token: opts.token,
202
+ activeWorkspaceId: opts.activeWorkspaceId,
203
+ apiUrl: opts.apiUrl,
204
+ };
205
+ if (opts.activeWorkspaceName) {
206
+ raw.activeWorkspaceName = opts.activeWorkspaceName;
207
+ }
208
+ writeRawConfigFile(configPath, raw);
209
+ return { configPath };
210
+ }
181
211
  function getActiveEnvConfigRef(raw) {
182
212
  if (raw?.activeEnv && raw?.environments) {
183
213
  const envName = raw.activeEnv;
package/dist/server.js CHANGED
@@ -5,8 +5,9 @@ import { getSkillByName, listSkills } from "./skills.js";
5
5
  import { authToolDefinitions, getAuthStatus } from "./tools/auth.js";
6
6
  import { blueprintCommitToolDefinitions, handleAddColumn, handleCommitBlueprint, } from "./tools/blueprint-commit.js";
7
7
  import { bootstrapCreateCampaign, bootstrapToolDefinitions, } from "./tools/bootstrap.js";
8
- import { campaignToolDefinitions, createCampaign, enrichSender, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
8
+ import { campaignToolDefinitions, createCampaign, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
9
9
  import { cellToolDefinitions, queueCells, updateCell } from "./tools/cells.js";
10
+ import { handleStartCliLogin, handleWaitForCliLogin, startCliLoginToolDef, waitForCliLoginToolDef, } from "./tools/cli-login.js";
10
11
  import { contextToolDefinitions, getCampaignContext, hydrateCampaignContextFromCampaign, markCampaignContextDirty, } from "./tools/context.js";
11
12
  import { addToCommentCampaign, addToConnectionCampaign, addToInmailCampaign, directCampaignToolDefinitions, getEngagedPosts, getOrCreateDirectCampaignTable, pauseDirectCampaign, startDirectCampaign, } from "./tools/direct-campaigns.js";
12
13
  import { bootstrapEngage, bootstrapEngageMulti, engageBootstrapToolDefinitions, } from "./tools/engage-bootstrap.js";
@@ -42,6 +43,8 @@ const server = new Server({
42
43
  const allTools = [
43
44
  ...campaignToolDefinitions,
44
45
  ...authToolDefinitions,
46
+ startCliLoginToolDef,
47
+ waitForCliLoginToolDef,
45
48
  ...bootstrapToolDefinitions,
46
49
  ...engageBootstrapToolDefinitions,
47
50
  ...engageDiscoveryToolDefinitions,
@@ -108,6 +111,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
108
111
  case "get_auth_status":
109
112
  result = await getAuthStatus();
110
113
  break;
114
+ case "start_cli_login":
115
+ result = await handleStartCliLogin(args);
116
+ break;
117
+ case "wait_for_cli_login":
118
+ result = await handleWaitForCliLogin(args);
119
+ break;
111
120
  case "bootstrap_engage":
112
121
  result = await bootstrapEngage(args);
113
122
  break;
@@ -168,9 +177,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
168
177
  case "add_teammate":
169
178
  result = await addTeammate(args);
170
179
  break;
171
- case "enrich_sender":
172
- result = await enrichSender(args?.linkedinUrl, args?.companyDomain, args?.forceRefresh);
173
- break;
174
180
  case "get_campaigns":
175
181
  result = await getCampaigns(args?.limit);
176
182
  break;
@@ -406,7 +412,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
406
412
  result = await fetchLinkedInPosts(args?.linkedinUrl, args?.limit);
407
413
  break;
408
414
  case "fetch_linkedin_profile":
409
- result = await fetchLinkedInProfile(args?.linkedinUrl);
415
+ result = await fetchLinkedInProfile(args?.linkedinUrl, {
416
+ full: args?.full === true,
417
+ });
410
418
  break;
411
419
  case "fetch_company":
412
420
  result = await fetchCompany(args?.companyUrl);
@@ -18,6 +18,14 @@ export type AuthStatus = {
18
18
  message: string;
19
19
  guidance: string;
20
20
  };
21
+ /**
22
+ * Verbatim instruction for the AGENT (not the user) when auth is missing.
23
+ * The agent MUST follow this verbatim and MUST NOT show error.guidance to
24
+ * the user — that's a non-interactive fallback. This field is the single
25
+ * source of truth for the FTUX magic-link flow so even a stale skill copy
26
+ * does the right thing.
27
+ */
28
+ agentInstruction?: string;
21
29
  };
22
30
  export declare const authToolDefinitions: {
23
31
  name: string;
@@ -22,7 +22,7 @@ function appendUpdateNotice(notice, update) {
22
22
  export const authToolDefinitions = [
23
23
  {
24
24
  name: "get_auth_status",
25
- description: "Verify Sellable authentication early. Call this before any campaign work. Also performs a cached Sellable package update check. Returns ok=false with clear guidance when the token is missing, revoked, or the active workspace is not set. IMPORTANT: When auth succeeds, ALWAYS tell the user the _userNotice message so they know which workspace is active and whether an update is available.",
25
+ description: "Verify Sellable authentication early. Call this before any campaign work. Also performs a cached Sellable package update check. Returns ok=false with clear guidance when the token is missing, revoked, or the active workspace is not set. IMPORTANT: When auth succeeds, ALWAYS tell the user the _userNotice message so they know which workspace is active and whether an update is available. CRITICAL: When auth FAILS with a missing/invalid token (error.type is 'config' or 'auth'), the response includes an `agentInstruction` field with the verbatim FTUX magic-link signup flow. Follow agentInstruction VERBATIM. Do NOT show the user error.guidance — that's a fallback for non-interactive contexts only.",
26
26
  inputSchema: {
27
27
  type: "object",
28
28
  properties: {},
@@ -110,6 +110,23 @@ export async function getAuthStatus() {
110
110
  };
111
111
  }
112
112
  catch (error) {
113
+ // FTUX directive: when the user is not yet signed in, tell the AGENT
114
+ // (not the user) to run the magic-link CLI handoff verbatim. Treat this
115
+ // as the single source of truth so even a stale skill does the right
116
+ // thing instead of dumping the technical fallback guidance.
117
+ const ftuxAgentInstruction = "AGENT INSTRUCTION (verbatim, do not paraphrase, do not show the user `error.guidance`): " +
118
+ "The user has not signed in to Sellable yet. Run the magic-link CLI handoff. " +
119
+ "1) Say to the user verbatim: `Welcome to Sellable. What's your email?` " +
120
+ "2) Wait for the user to type their email in normal chat (do NOT use AskUserQuestion / request_user_input). " +
121
+ "3) Call `mcp__sellable__start_cli_login({ email })` with that email. " +
122
+ "4) On `ok: true`, say verbatim (substituting the email exactly as typed): " +
123
+ "`Magic link sent to {email}. Click it from your inbox — I'll wait. " +
124
+ "(If your team already has a Sellable workspace, ask an admin to invite you instead — that gets you straight into their data.)` " +
125
+ "5) Call `mcp__sellable__wait_for_cli_login({ sessionId })` using the sessionId returned by start_cli_login. " +
126
+ "6) If the result is `error.type === 'tool_timeout_guard'`, IMMEDIATELY re-call wait_for_cli_login with the SAME sessionId — do not narrate, do not call start_cli_login again. Loop until you get a different result. " +
127
+ "7) On `ok: true`, the user is signed in and `~/.sellable/config.json` has been written. Branch on `isReturningUser`: " +
128
+ "if true, say `You're in — using your {activeWorkspaceName} workspace.\\n\\nNow — paste the LinkedIn profile URL of the person you want to send from.`; " +
129
+ "if false, say `Welcome to Sellable — created {activeWorkspaceName} for you.\\n\\nNow — paste the LinkedIn profile URL of the person you want to send from.`";
113
130
  if (error instanceof SellableApiError && error.isAuthError) {
114
131
  return {
115
132
  ...base,
@@ -120,6 +137,7 @@ export async function getAuthStatus() {
120
137
  guidance: error.guidance ||
121
138
  `Update ${configPath} with a valid token, then retry get_auth_status.`,
122
139
  },
140
+ agentInstruction: ftuxAgentInstruction,
123
141
  };
124
142
  }
125
143
  const message = error instanceof Error ? error.message : String(error);
@@ -136,6 +154,7 @@ export async function getAuthStatus() {
136
154
  message,
137
155
  guidance,
138
156
  },
157
+ agentInstruction: isConfigError ? ftuxAgentInstruction : undefined,
139
158
  };
140
159
  }
141
160
  }
@@ -117,6 +117,7 @@ export interface CreateCampaignInput {
117
117
  campaignId?: string;
118
118
  name?: string;
119
119
  clientProspectId?: string;
120
+ senderLinkedinUrl?: string;
120
121
  offerPositioning?: unknown;
121
122
  campaignBrief?: string;
122
123
  messageGenerationMode?: "template" | "ai-generated";
@@ -140,48 +141,6 @@ export interface UpdateCampaignInput {
140
141
  rubric?: unknown[];
141
142
  }
142
143
  export declare const campaignToolDefinitions: ({
143
- name: string;
144
- description: string;
145
- inputSchema: {
146
- type: string;
147
- properties: {
148
- linkedinUrl: {
149
- type: string;
150
- description: string;
151
- };
152
- companyDomain: {
153
- type: string;
154
- description: string;
155
- };
156
- forceRefresh: {
157
- type: string;
158
- description: string;
159
- };
160
- limit?: undefined;
161
- campaignId?: undefined;
162
- tableId?: undefined;
163
- leadLimit?: undefined;
164
- page?: undefined;
165
- filters?: undefined;
166
- name?: undefined;
167
- clientProspectId?: undefined;
168
- offerPositioning?: undefined;
169
- campaignBrief?: undefined;
170
- messageGenerationMode?: undefined;
171
- currentStep?: undefined;
172
- leadSourceType?: undefined;
173
- leadSourceProvider?: undefined;
174
- selectedLeadListId?: undefined;
175
- senderIds?: undefined;
176
- interactionMode?: undefined;
177
- enableICPFilters?: undefined;
178
- useMessagingTemplate?: undefined;
179
- rubric?: undefined;
180
- };
181
- required: string[];
182
- additionalProperties?: undefined;
183
- };
184
- } | {
185
144
  name: string;
186
145
  description: string;
187
146
  inputSchema: {
@@ -191,9 +150,6 @@ export declare const campaignToolDefinitions: ({
191
150
  type: string;
192
151
  description: string;
193
152
  };
194
- linkedinUrl?: undefined;
195
- companyDomain?: undefined;
196
- forceRefresh?: undefined;
197
153
  campaignId?: undefined;
198
154
  tableId?: undefined;
199
155
  leadLimit?: undefined;
@@ -201,6 +157,7 @@ export declare const campaignToolDefinitions: ({
201
157
  filters?: undefined;
202
158
  name?: undefined;
203
159
  clientProspectId?: undefined;
160
+ senderLinkedinUrl?: undefined;
204
161
  offerPositioning?: undefined;
205
162
  campaignBrief?: undefined;
206
163
  messageGenerationMode?: undefined;
@@ -227,9 +184,6 @@ export declare const campaignToolDefinitions: ({
227
184
  type: string;
228
185
  description: string;
229
186
  };
230
- linkedinUrl?: undefined;
231
- companyDomain?: undefined;
232
- forceRefresh?: undefined;
233
187
  limit?: undefined;
234
188
  tableId?: undefined;
235
189
  leadLimit?: undefined;
@@ -237,6 +191,7 @@ export declare const campaignToolDefinitions: ({
237
191
  filters?: undefined;
238
192
  name?: undefined;
239
193
  clientProspectId?: undefined;
194
+ senderLinkedinUrl?: undefined;
240
195
  offerPositioning?: undefined;
241
196
  campaignBrief?: undefined;
242
197
  messageGenerationMode?: undefined;
@@ -310,12 +265,10 @@ export declare const campaignToolDefinitions: ({
310
265
  additionalProperties: boolean;
311
266
  };
312
267
  };
313
- linkedinUrl?: undefined;
314
- companyDomain?: undefined;
315
- forceRefresh?: undefined;
316
268
  limit?: undefined;
317
269
  name?: undefined;
318
270
  clientProspectId?: undefined;
271
+ senderLinkedinUrl?: undefined;
319
272
  offerPositioning?: undefined;
320
273
  campaignBrief?: undefined;
321
274
  messageGenerationMode?: undefined;
@@ -350,6 +303,10 @@ export declare const campaignToolDefinitions: ({
350
303
  type: string;
351
304
  description: string;
352
305
  };
306
+ senderLinkedinUrl: {
307
+ type: string;
308
+ description: string;
309
+ };
353
310
  offerPositioning: {
354
311
  type: string;
355
312
  description: string;
@@ -387,9 +344,6 @@ export declare const campaignToolDefinitions: ({
387
344
  };
388
345
  description: string;
389
346
  };
390
- linkedinUrl?: undefined;
391
- companyDomain?: undefined;
392
- forceRefresh?: undefined;
393
347
  limit?: undefined;
394
348
  tableId?: undefined;
395
349
  leadLimit?: undefined;
@@ -462,9 +416,6 @@ export declare const campaignToolDefinitions: ({
462
416
  type: string;
463
417
  description: string;
464
418
  };
465
- linkedinUrl?: undefined;
466
- companyDomain?: undefined;
467
- forceRefresh?: undefined;
468
419
  limit?: undefined;
469
420
  tableId?: undefined;
470
421
  leadLimit?: undefined;
@@ -472,6 +423,7 @@ export declare const campaignToolDefinitions: ({
472
423
  filters?: undefined;
473
424
  name?: undefined;
474
425
  clientProspectId?: undefined;
426
+ senderLinkedinUrl?: undefined;
475
427
  messageGenerationMode?: undefined;
476
428
  };
477
429
  required: string[];
@@ -491,9 +443,6 @@ export declare const campaignToolDefinitions: ({
491
443
  type: string;
492
444
  description: string;
493
445
  };
494
- linkedinUrl?: undefined;
495
- companyDomain?: undefined;
496
- forceRefresh?: undefined;
497
446
  limit?: undefined;
498
447
  tableId?: undefined;
499
448
  leadLimit?: undefined;
@@ -501,6 +450,7 @@ export declare const campaignToolDefinitions: ({
501
450
  filters?: undefined;
502
451
  name?: undefined;
503
452
  clientProspectId?: undefined;
453
+ senderLinkedinUrl?: undefined;
504
454
  offerPositioning?: undefined;
505
455
  messageGenerationMode?: undefined;
506
456
  currentStep?: undefined;
@@ -549,41 +499,4 @@ export declare function updateCampaignBrief(campaignId: string, campaignBrief: s
549
499
  success: boolean;
550
500
  campaignBrief: string;
551
501
  }>;
552
- export interface EnrichSenderResult {
553
- clientProspectId: string;
554
- sender: {
555
- fullName: string;
556
- headline?: string;
557
- title?: string;
558
- company?: string;
559
- };
560
- companyDomain: string;
561
- enrichmentStatus: "partial" | "complete";
562
- companySnapshot?: {
563
- name: string;
564
- domain: string;
565
- industry?: string;
566
- employeeRange?: string;
567
- description?: string;
568
- linkedinUrl?: string;
569
- } | null;
570
- senderBackground?: {
571
- experience: {
572
- title: string;
573
- company: string;
574
- duration?: string;
575
- }[];
576
- education?: string;
577
- followerCount?: number;
578
- connectionCount?: number;
579
- } | null;
580
- proofDigest?: {
581
- caseStudyCount: number;
582
- caseStudySummary: string | null;
583
- reviewHighlight: string | null;
584
- positioningOneLiner: string | null;
585
- keyDifferentiators: string[];
586
- } | null;
587
- }
588
- export declare function enrichSender(linkedinUrl: string, companyDomain?: string, forceRefresh?: boolean): Promise<EnrichSenderResult>;
589
502
  export {};
@@ -1,6 +1,6 @@
1
1
  import { getApi } from "../api.js";
2
2
  import { getConfig } from "../auth.js";
3
- import { assertCreateCampaignPromptLoaded, assertNetNewCreateCampaignResearchReady, markSenderEnrichmentObserved, } from "./flow-preflight.js";
3
+ import { assertCreateCampaignPromptLoaded, assertNetNewCreateCampaignResearchReady, } from "./flow-preflight.js";
4
4
  import { setCampaignInteractionMode, } from "./interaction-mode.js";
5
5
  import { fetchCampaignRubrics } from "./processing.js";
6
6
  const LEAD_SOURCE_PROVIDERS = {
@@ -27,28 +27,6 @@ function buildWatchUrl(config, redirect) {
27
27
  return `${config.apiUrl}/auth/continue?token=${config.token}&redirect=${redirect}${workspaceParam}`;
28
28
  }
29
29
  export const campaignToolDefinitions = [
30
- {
31
- name: "enrich_sender",
32
- description: "Enrich a LinkedIn profile to get the clientProspectId needed for create_campaign. Fast return (~5-8s) with background enrichment. Call this first with the sender's LinkedIn URL before creating a campaign.",
33
- inputSchema: {
34
- type: "object",
35
- properties: {
36
- linkedinUrl: {
37
- type: "string",
38
- description: "Full LinkedIn profile URL (e.g., https://linkedin.com/in/username)",
39
- },
40
- companyDomain: {
41
- type: "string",
42
- description: "Optional company domain to use instead of resolving from profile",
43
- },
44
- forceRefresh: {
45
- type: "boolean",
46
- description: "Skip cache and fetch fresh LinkedIn data. Use when you need the latest profile info.",
47
- },
48
- },
49
- required: ["linkedinUrl"],
50
- },
51
- },
52
30
  {
53
31
  name: "get_campaigns",
54
32
  description: "List campaigns for the authenticated user. Returns id, name, createdAt. Ordered by most recent first.",
@@ -145,7 +123,7 @@ export const campaignToolDefinitions = [
145
123
  },
146
124
  {
147
125
  name: "create_campaign",
148
- description: 'Create a new campaign offer OR resume an existing one. Low-level write tool: load create-campaign workflow instructions first via get_subskill_prompt({ subskillName: "create-campaign" }) (or bootstrap_create_campaign + nextStep). If campaignId is provided, this tool returns the watchUrl + state for that campaign instead of creating a new campaign.',
126
+ description: 'Create a new campaign offer OR resume an existing one. Low-level write tool: load create-campaign workflow instructions first via get_subskill_prompt({ subskillName: "create-campaign" }) (or bootstrap_create_campaign + nextStep). If campaignId is provided, this tool returns the watchUrl + state for that campaign instead of creating a new campaign.\n\nINPUTS:\n- `clientProspectId` is REQUIRED for net-new campaigns. It is the EnrichedProspect row ID for the campaign sender (used by ICP scoring, message generation, brief rendering — every downstream consumer reads sender context from `campaign.clientProspect`).\n- To obtain a `clientProspectId`, run the sender-enrichment flow first: this materializes an EnrichedProspect row from the sender\'s LinkedIn profile URL and returns its ID.\n- `senderLinkedinUrl` may be passed as informational metadata but is NOT a substitute for `clientProspectId` — the API will reject the create if `clientProspectId` is missing.\n\nPREREQUISITES:\n- The /research-sender skill must have been run for this sender (produces enrichment data and the EnrichedProspect row whose ID is `clientProspectId`).',
149
127
  inputSchema: {
150
128
  type: "object",
151
129
  properties: {
@@ -159,7 +137,13 @@ export const campaignToolDefinitions = [
159
137
  },
160
138
  clientProspectId: {
161
139
  type: "string",
162
- description: "Enriched client prospect ID",
140
+ description: "REQUIRED for net-new campaigns. EnrichedProspect row ID for the campaign sender. Produced by the sender-enrichment flow (research-sender). The API will reject the create with a 400 if this is missing.",
141
+ },
142
+ // `senderLinkedinUrl` (not `linkedinUrl`) to disambiguate from
143
+ // prospect-side `linkedinUrl` fields used in other tools (research-prospect etc.).
144
+ senderLinkedinUrl: {
145
+ type: "string",
146
+ description: "Optional informational metadata: the sender's LinkedIn profile URL. NOT a substitute for clientProspectId — to materialize a prospect from a URL, run the sender-enrichment flow first.",
163
147
  },
164
148
  offerPositioning: {
165
149
  type: "object",
@@ -611,21 +595,34 @@ export async function createCampaign(input) {
611
595
  const missing = [];
612
596
  if (!input.name)
613
597
  missing.push("name");
614
- if (!input.clientProspectId)
615
- missing.push("clientProspectId");
616
598
  if (!input.campaignBrief)
617
599
  missing.push("campaignBrief");
600
+ // clientProspectId is required for net-new campaigns. Every downstream
601
+ // consumer (ICP scoring, message generation, brief rendering) reads sender
602
+ // context from `campaign.clientProspect`. senderLinkedinUrl is informational
603
+ // metadata only — it is NOT a substitute and the API will reject the create
604
+ // if clientProspectId is missing.
605
+ if (!input.clientProspectId) {
606
+ missing.push("clientProspectId");
607
+ }
608
+ // Cheap URL sanity check on senderLinkedinUrl when supplied.
609
+ if (input.senderLinkedinUrl &&
610
+ typeof input.senderLinkedinUrl === "string" &&
611
+ !input.senderLinkedinUrl.includes("linkedin.com")) {
612
+ throw new Error("VALIDATION_ERROR: senderLinkedinUrl must be a LinkedIn URL (must contain 'linkedin.com'). Got: " +
613
+ input.senderLinkedinUrl);
614
+ }
618
615
  if (missing.length > 0) {
619
616
  throw new Error("VALIDATION_ERROR: create_campaign requires either campaignId (resume) " +
620
617
  `or all create fields. Missing: ${missing.join(", ")}.\n\n` +
621
618
  "Remediation:\n" +
622
619
  '- For full workflow, call get_subskill_prompt({ subskillName: "create-campaign" }) and follow it.\n' +
623
- '- For net-new campaign creation, call enrich_sender first, then call get_subskill_prompt({ subskillName: "research-sender" }), run sender research, call complete_sender_research(...), and only then call create_campaign.\n' +
620
+ "- For net-new campaign creation: run the `research-sender` flow first to materialize an EnrichedProspect for the sender from their LinkedIn profile URL, then pass the resulting prospect ID as `clientProspectId`. The campaign sender context (used by ICP scoring, message generation, brief rendering) is read from `campaign.clientProspect`, so this row must exist before the campaign is minted.\n" +
624
621
  "- For resume, call create_campaign with campaignId only.");
625
622
  }
626
623
  assertNetNewCreateCampaignResearchReady();
627
624
  const name = input.name;
628
- const clientProspectId = input.clientProspectId;
625
+ const clientProspectId = input.clientProspectId ?? null;
629
626
  const campaignBrief = input.campaignBrief;
630
627
  // Validate required campaignBrief - must be non-empty markdown
631
628
  // Normalize escaped newlines - MCP tool calls pass literal "\n" text (backslash + n)
@@ -680,6 +677,9 @@ export async function createCampaign(input) {
680
677
  ...apiInput,
681
678
  name,
682
679
  clientProspectId,
680
+ // Forward senderLinkedinUrl alongside clientProspectId. Downstream API may
681
+ // ignore it for now; future phase wires it.
682
+ senderLinkedinUrl: input.senderLinkedinUrl ?? null,
683
683
  offerPositioning,
684
684
  ...(input.leadSourceProvider !== undefined
685
685
  ? { leadSourceProvider: normalizedLeadSourceProvider }
@@ -769,124 +769,3 @@ export async function updateCampaignBrief(campaignId, campaignBrief) {
769
769
  const api = getApi();
770
770
  return api.patch(`/api/v3/mcp/campaigns/${campaignId}`, { campaignBrief });
771
771
  }
772
- export async function enrichSender(linkedinUrl, companyDomain, forceRefresh) {
773
- const api = getApi();
774
- const result = await api.post(`/api/v4/enrich-prospect`, {
775
- linkedinUrl,
776
- companyDomain,
777
- force: forceRefresh,
778
- });
779
- markSenderEnrichmentObserved({
780
- enrichmentStatus: result.enrichmentStatus,
781
- caseStudies: result.caseStudies,
782
- proof: result.reviews,
783
- });
784
- // Serialize to compact digests — strip raw blobs to save context tokens
785
- const personData = (result.personData || {});
786
- const companyData = (result.companyData || {});
787
- const positioning = (result.positioning || {});
788
- const caseStudies = result.caseStudies;
789
- const reviews = result.reviews;
790
- const base = {
791
- clientProspectId: result.id,
792
- sender: {
793
- fullName: personData.fullName || "",
794
- headline: personData.headline,
795
- title: personData.currentPosition?.title,
796
- company: personData.currentPosition?.company?.name,
797
- },
798
- companyDomain: result.companyDomain,
799
- enrichmentStatus: result.enrichmentStatus,
800
- };
801
- if (result.enrichmentStatus !== "complete")
802
- return base;
803
- // Build compact companySnapshot
804
- base.companySnapshot = {
805
- name: companyData.name || "",
806
- domain: companyData.domain || result.companyDomain,
807
- industry: companyData.industry,
808
- employeeRange: companyData.employeeRange,
809
- description: companyData.description,
810
- linkedinUrl: companyData.linkedinUrl,
811
- };
812
- // Build compact senderBackground
813
- const experience = personData.experience || [];
814
- base.senderBackground = {
815
- experience: experience.slice(0, 4).map((exp) => ({
816
- title: exp.title || "",
817
- company: exp.companyName || exp.company || "",
818
- duration: exp.duration,
819
- })),
820
- education: personData.education?.[0]?.schoolName,
821
- followerCount: personData.followerCount,
822
- connectionCount: personData.connectionCount,
823
- };
824
- // Build compact proofDigest from caseStudies + reviews + positioning
825
- let caseStudyCount = 0;
826
- let caseStudySummary = null;
827
- if (caseStudies) {
828
- try {
829
- const parsed = JSON.parse(caseStudies);
830
- const studies = parsed.caseStudies || [];
831
- caseStudyCount = studies.length;
832
- caseStudySummary = parsed.summary || null;
833
- }
834
- catch {
835
- // ignore parse errors
836
- }
837
- }
838
- let reviewHighlight = null;
839
- if (reviews) {
840
- try {
841
- const parsed = JSON.parse(reviews);
842
- // Cross-check review sites against confirmed company domain
843
- const sites = (parsed.reviewSites || []);
844
- const relevantSites = sites.filter((s) => {
845
- const url = s.url || "";
846
- const domain = result.companyDomain.replace(/^www\./, "");
847
- const companyName = (companyData.name || "").toLowerCase();
848
- return (url.includes(domain) ||
849
- url.includes(companyName.replace(/\s+/g, "-")) ||
850
- url.includes(companyName.replace(/\s+/g, "")));
851
- });
852
- if (relevantSites.length > 0) {
853
- reviewHighlight = relevantSites
854
- .map((s) => `${s.platform}: ${s.reviewCount || "reviews found"} (${s.url})`)
855
- .join("; ");
856
- }
857
- // Fall back to summary if no site-level match but summary exists
858
- if (!reviewHighlight && parsed.summary) {
859
- reviewHighlight = parsed.summary;
860
- }
861
- }
862
- catch {
863
- // ignore parse errors
864
- }
865
- }
866
- let positioningOneLiner = null;
867
- let keyDifferentiators = [];
868
- if (positioning) {
869
- try {
870
- const desc = typeof positioning === "string" ? JSON.parse(positioning) : positioning;
871
- const descObj = typeof desc.description === "string"
872
- ? JSON.parse(desc.description)
873
- : desc.description || {};
874
- positioningOneLiner =
875
- descObj.UniqueValueProposition ||
876
- descObj.ExamplePositioningStatement ||
877
- null;
878
- keyDifferentiators = descObj.keyDifferentiators || [];
879
- }
880
- catch {
881
- // ignore parse errors
882
- }
883
- }
884
- base.proofDigest = {
885
- caseStudyCount,
886
- caseStudySummary,
887
- reviewHighlight,
888
- positioningOneLiner,
889
- keyDifferentiators,
890
- };
891
- return base;
892
- }