@sellable/mcp 0.1.255 → 0.1.257

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/api.d.ts CHANGED
@@ -10,8 +10,11 @@ export declare class SellableApiError extends Error {
10
10
  constructor(status: number, body: string, guidance?: string);
11
11
  }
12
12
  export declare class SellableApi {
13
+ private buildError;
14
+ private requestResponse;
13
15
  private request;
14
16
  get<T>(path: string): Promise<T>;
17
+ getText(path: string): Promise<string>;
15
18
  post<T>(path: string, body?: object): Promise<T>;
16
19
  put<T>(path: string, body?: object): Promise<T>;
17
20
  patch<T>(path: string, body?: object): Promise<T>;
package/dist/api.js CHANGED
@@ -14,7 +14,21 @@ export class SellableApiError extends Error {
14
14
  }
15
15
  }
16
16
  export class SellableApi {
17
- async request(method, path, body) {
17
+ buildError(status, errorText) {
18
+ const isAuthError = status === 401 || status === 403;
19
+ const missingWorkspace = status === 400 && errorText.includes("Workspace");
20
+ const guidance = isAuthError
21
+ ? "Sellable authentication failed.\n\n" +
22
+ `Update ${getConfigPath()} with a valid token from Sellable Settings -> Integrations, then retry.\n\n` +
23
+ "NOTE: If the token was just updated via the LLM (editing the config file), " +
24
+ "the change should take effect immediately. If it still fails, restart Claude Code to restart the MCP server."
25
+ : missingWorkspace
26
+ ? "No active workspace selected.\n\n" +
27
+ "Run list_workspaces then set_active_workspace to choose a workspace."
28
+ : undefined;
29
+ return new SellableApiError(status, errorText, guidance);
30
+ }
31
+ async requestResponse(method, path, body) {
18
32
  // Re-read config on every request so workspace switches take effect immediately
19
33
  const config = getConfig();
20
34
  const url = `${config.apiUrl}${path}`;
@@ -30,24 +44,21 @@ export class SellableApi {
30
44
  });
31
45
  if (!response.ok) {
32
46
  const errorText = await response.text();
33
- const isAuthError = response.status === 401 || response.status === 403;
34
- const missingWorkspace = response.status === 400 && errorText.includes("Workspace");
35
- const guidance = isAuthError
36
- ? "Sellable authentication failed.\n\n" +
37
- `Update ${getConfigPath()} with a valid token from Sellable Settings -> Integrations, then retry.\n\n` +
38
- "NOTE: If the token was just updated via the LLM (editing the config file), " +
39
- "the change should take effect immediately. If it still fails, restart Claude Code to restart the MCP server."
40
- : missingWorkspace
41
- ? "No active workspace selected.\n\n" +
42
- "Run list_workspaces then set_active_workspace to choose a workspace."
43
- : undefined;
44
- throw new SellableApiError(response.status, errorText, guidance);
47
+ throw this.buildError(response.status, errorText);
45
48
  }
49
+ return response;
50
+ }
51
+ async request(method, path, body) {
52
+ const response = await this.requestResponse(method, path, body);
46
53
  return response.json();
47
54
  }
48
55
  async get(path) {
49
56
  return this.request("GET", path);
50
57
  }
58
+ async getText(path) {
59
+ const response = await this.requestResponse("GET", path);
60
+ return response.text();
61
+ }
51
62
  async post(path, body) {
52
63
  return this.request("POST", path, body);
53
64
  }
package/dist/index-dev.js CHANGED
File without changes
package/dist/index.js CHANGED
File without changes
package/dist/server.js CHANGED
@@ -5,12 +5,12 @@ import { getSkillByName, listSkills } from "./skills.js";
5
5
  import { getAuthStatus } from "./tools/auth.js";
6
6
  import { handleAddColumn, handleCommitBlueprint, } from "./tools/blueprint-commit.js";
7
7
  import { bootstrapCreateCampaign } from "./tools/bootstrap.js";
8
- import { createCampaign, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
9
8
  import { getCampaignTableSchema, queueCampaignCells, reviseMessageTemplateAndRerun, selectCampaignCells, waitForCampaignProcessing, } from "./tools/campaign-processing.js";
9
+ import { createCampaign, duplicateCampaign, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
10
10
  import { queueCells, updateCell } from "./tools/cells.js";
11
11
  import { handleStartCliLogin, handleWaitForCliLogin, } from "./tools/cli-login.js";
12
+ import { capturePostIdeaTool, getPostDraftTool, getPostIdeaTool, getPublishedPostTool, listPostDraftIterationsTool, listPostDraftsTool, listPostIdeasTool, listPublishedPostsTool, markPostPublishedTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
12
13
  import { getCampaignContext, hydrateCampaignContextFromCampaign, markCampaignContextDirty, } from "./tools/context.js";
13
- import { capturePostIdeaTool, getPublishedPostTool, getPostDraftTool, getPostIdeaTool, listPostDraftIterationsTool, listPostDraftsTool, listPostIdeasTool, listPublishedPostsTool, markPostPublishedTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
14
14
  import { addToCommentCampaign, addToConnectionCampaign, addToInmailCampaign, getEngagedPosts, getOrCreateDirectCampaignTable, pauseDirectCampaign, startDirectCampaign, } from "./tools/direct-campaigns.js";
15
15
  import { bootstrapEngage, bootstrapEngageMulti, } from "./tools/engage-bootstrap.js";
16
16
  import { searchEngagementPosts } from "./tools/engage-discovery.js";
@@ -18,7 +18,7 @@ import { copySenderConfigTool, getEngageMemoryTool, migrateFlatConfigsTool, reco
18
18
  import { getEngageStateTool, setEngageStateTool, } from "./tools/engage-state.js";
19
19
  import { bulkEnrichWithProspeo, enrichWithProspeo, getProspeoCredits, } from "./tools/enrichment.js";
20
20
  import { getCampaignFramework } from "./tools/framework.js";
21
- import { cancelLeadImport, confirmProspeoCompanyAccounts, confirmLeadList, getProviderPrompt, importLeads, listDncEntriesTool, loadCsvDncEntriesTool, loadCsvDomains, loadCsvLinkedinLeads, lookupSalesNavFilter, saveDomainFilters, searchApollo, searchProspeoCompanies, searchProspeo, searchSalesNav, searchSignals, selectPromisingPosts, setHeadlineICPCriteria, } from "./tools/leads.js";
21
+ import { cancelLeadImport, confirmLeadList, confirmProspeoCompanyAccounts, getProviderPrompt, importLeads, loadCsvDncEntriesTool, loadCsvDomains, loadCsvLinkedinLeads, lookupSalesNavFilter, saveDomainFilters, searchApollo, searchProspeo, searchProspeoCompanies, searchSalesNav, searchSignals, selectPromisingPosts, setHeadlineICPCriteria, } from "./tools/leads.js";
22
22
  import { fetchCompany, fetchCompanyPosts, fetchLinkedInPosts, fetchLinkedInProfile, fetchPostEngagers, getLinkedInProfile, getUserPosts, } from "./tools/linkedin.js";
23
23
  import { getCampaignNavigationState } from "./tools/navigation.js";
24
24
  import { addOnDemandLeads, createOnDemandCampaign, createOnDemandTable, initOnDemandSequence, pauseOnDemandCampaign, startOnDemandCampaign, } from "./tools/one-off.js";
@@ -30,7 +30,7 @@ import { getRows, getTableRows, getTableRowsMinimal } from "./tools/rows.js";
30
30
  import { addRubricItem, checkRubric, deleteRubricItem, draftRubrics, saveRubrics, selectNecessaryRubrics, updateRubricItem, waitForRubricResults, } from "./tools/rubrics.js";
31
31
  import { getSender, listSenders } from "./tools/senders.js";
32
32
  import { attachRecommendedSequence, attachSequence, createWorkflowTable, } from "./tools/sequencer.js";
33
- import { listTables } from "./tools/tables.js";
33
+ import { exportTableCsv, listTables } from "./tools/tables.js";
34
34
  import { handleVerifyTableRow } from "./tools/verify-row.js";
35
35
  import { sanitizeWatchUrlsForMcpResult } from "./tools/watch-url-security.js";
36
36
  import { addTeammate, createWorkspace, getActiveWorkspace, listWorkspaces, setActiveWorkspace, } from "./tools/workspaces.js";
@@ -208,6 +208,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
208
208
  case "pause_campaign":
209
209
  result = await pauseCampaign(args?.campaignId);
210
210
  break;
211
+ case "duplicate_campaign":
212
+ result = await duplicateCampaign(args?.campaignId);
213
+ if (result?.campaignOfferId) {
214
+ markCampaignContextDirty(result.campaignOfferId, "duplicate_campaign");
215
+ }
216
+ break;
211
217
  case "update_campaign_brief":
212
218
  result = await updateCampaignBrief(args?.campaignId, args?.campaignBrief);
213
219
  if (args?.campaignId) {
@@ -277,6 +283,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
277
283
  case "list_tables":
278
284
  result = await listTables(args);
279
285
  break;
286
+ case "export_table_csv":
287
+ result = await exportTableCsv(args);
288
+ break;
280
289
  case "commit_blueprint":
281
290
  result = await handleCommitBlueprint(args);
282
291
  break;
@@ -366,9 +375,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
366
375
  case "load_csv_dnc_entries":
367
376
  result = await loadCsvDncEntriesTool(args);
368
377
  break;
369
- case "list_dnc_entries":
370
- result = await listDncEntriesTool(args);
371
- break;
372
378
  case "save_domain_filters":
373
379
  result = await saveDomainFilters(args);
374
380
  break;
@@ -150,6 +150,42 @@ export interface UpdateCampaignInput {
150
150
  rubric?: unknown[];
151
151
  }
152
152
  export declare const campaignToolDefinitions: ({
153
+ name: string;
154
+ description: string;
155
+ inputSchema: {
156
+ type: string;
157
+ properties: {
158
+ campaignId: {
159
+ type: string;
160
+ description: string;
161
+ };
162
+ limit?: undefined;
163
+ tableId?: undefined;
164
+ leadLimit?: undefined;
165
+ page?: undefined;
166
+ filters?: undefined;
167
+ name?: undefined;
168
+ clientProspectId?: undefined;
169
+ senderLinkedinUrl?: undefined;
170
+ offerPositioning?: undefined;
171
+ campaignBrief?: undefined;
172
+ messageGenerationMode?: undefined;
173
+ currentStep?: undefined;
174
+ watchNarration?: undefined;
175
+ leadSourceType?: undefined;
176
+ leadSourceProvider?: undefined;
177
+ selectedLeadListId?: undefined;
178
+ senderIds?: undefined;
179
+ interactionMode?: undefined;
180
+ enableICPFilters?: undefined;
181
+ useMessagingTemplate?: undefined;
182
+ rubric?: undefined;
183
+ flowVersion?: undefined;
184
+ };
185
+ required: string[];
186
+ additionalProperties: boolean;
187
+ };
188
+ } | {
153
189
  name: string;
154
190
  description: string;
155
191
  inputSchema: {
@@ -600,6 +636,11 @@ export declare function startCampaign(campaignId: string): Promise<{
600
636
  export declare function pauseCampaign(campaignId: string): Promise<{
601
637
  success: boolean;
602
638
  }>;
639
+ export declare function duplicateCampaign(campaignId: string): Promise<{
640
+ campaignOfferId: string;
641
+ campaignName: string;
642
+ workflowTableId: string | null;
643
+ }>;
603
644
  export declare function updateCampaignBrief(campaignId: string, campaignBrief: string): Promise<{
604
645
  success: boolean;
605
646
  campaignBrief: string;
@@ -49,8 +49,7 @@ function getCampaignBuilderWatchModeFromUrl(watchUrl) {
49
49
  export function getCampaignBuilderWatchModeDriverLabel(mode = getCampaignBuilderWatchModeParam()) {
50
50
  return mode === "codex" ? "Codex" : "Claude Code";
51
51
  }
52
- export function buildCampaignWatchHandoffMarkdown(watchUrl, mode = getCampaignBuilderWatchModeFromUrl(watchUrl) ??
53
- getCampaignBuilderWatchModeParam()) {
52
+ export function buildCampaignWatchHandoffMarkdown(watchUrl, mode = getCampaignBuilderWatchModeFromUrl(watchUrl) ?? getCampaignBuilderWatchModeParam()) {
54
53
  const driverLabel = getCampaignBuilderWatchModeDriverLabel(mode);
55
54
  const headline = `WATCH ${driverLabel.toUpperCase()} BUILD THE CAMPAIGN LIVE`;
56
55
  return [
@@ -85,6 +84,21 @@ function assertBriefHandoffWatchUrl(watchUrl, campaignId) {
85
84
  "with create_campaign({ campaignId }) or get_campaign before asking for approval.");
86
85
  }
87
86
  export const campaignToolDefinitions = [
87
+ {
88
+ name: "duplicate_campaign",
89
+ description: "Duplicate a campaign by calling Sellable's existing duplicate endpoint. Returns campaignOfferId, campaignName, and workflowTableId for the new copy.",
90
+ inputSchema: {
91
+ type: "object",
92
+ properties: {
93
+ campaignId: {
94
+ type: "string",
95
+ description: "Source campaign ID to duplicate.",
96
+ },
97
+ },
98
+ required: ["campaignId"],
99
+ additionalProperties: false,
100
+ },
101
+ },
88
102
  {
89
103
  name: "get_campaigns",
90
104
  description: "List campaigns for the authenticated user. Returns id, name, createdAt. Ordered by most recent first.",
@@ -845,7 +859,8 @@ export async function createCampaign(input) {
845
859
  name,
846
860
  clientProspectId,
847
861
  offerPositioning,
848
- ...(shouldPersistSourceStateOnCreate && input.leadSourceProvider !== undefined
862
+ ...(shouldPersistSourceStateOnCreate &&
863
+ input.leadSourceProvider !== undefined
849
864
  ? { leadSourceProvider: normalizedLeadSourceProvider }
850
865
  : {}),
851
866
  currentStep: effectiveCurrentStep,
@@ -950,6 +965,10 @@ export async function pauseCampaign(campaignId) {
950
965
  const api = getApi();
951
966
  return api.post(`/api/v3/campaigns/${campaignId}/pause`);
952
967
  }
968
+ export async function duplicateCampaign(campaignId) {
969
+ const api = getApi();
970
+ return api.post(`/api/v3/campaigns/${campaignId}/duplicate`);
971
+ }
953
972
  export async function updateCampaignBrief(campaignId, campaignBrief) {
954
973
  const api = getApi();
955
974
  return api.patch(`/api/v3/mcp/campaigns/${campaignId}`, { campaignBrief });
@@ -1,4 +1,3 @@
1
- type DncSourceType = "all" | "manual" | "hubspot";
2
1
  type InvalidDncRow = {
3
2
  row: number;
4
3
  column: string | null;
@@ -15,41 +14,6 @@ export type LoadCsvDncEntriesInput = {
15
14
  confirmationToken?: string;
16
15
  confirmed?: boolean;
17
16
  };
18
- export type ListDncEntriesInput = {
19
- page?: number;
20
- limit?: number;
21
- search?: string;
22
- listName?: string;
23
- sourceType?: DncSourceType;
24
- includeDeleted?: boolean;
25
- };
26
- export declare function listDncEntries(input?: ListDncEntriesInput): Promise<{
27
- ok: boolean;
28
- workspaceId: string;
29
- workspaceName: string;
30
- pagination: {
31
- total: number;
32
- page: number;
33
- limit: number;
34
- totalPages: number;
35
- };
36
- listNames: string[];
37
- entries: {
38
- id: string | null;
39
- type: string;
40
- domain: string | null;
41
- linkedin: string | null;
42
- linkedinUsername: string | null;
43
- name: string | null;
44
- source: {
45
- type: string;
46
- label: string;
47
- listName: string | null;
48
- };
49
- createdAt: string | null;
50
- }[];
51
- guidance: string;
52
- }>;
53
17
  export declare function loadCsvDncEntries(input: LoadCsvDncEntriesInput): Promise<{
54
18
  guidance: string;
55
19
  createdCount: number;
@@ -14,7 +14,6 @@ const CONFIRMATION_TOKEN_VERSION = "csv-dnc-preview-v1";
14
14
  const confirmationSecret = randomBytes(32).toString("hex");
15
15
  const MAX_DNC_UPLOAD_BYTES = 5 * 1024 * 1024;
16
16
  const MAX_DNC_CANDIDATES = 7500;
17
- const MAX_DNC_LIST_LIMIT = 100;
18
17
  const STRONG_DOMAIN_HEADER_KEYS = new Set([
19
18
  "domain",
20
19
  "website",
@@ -326,84 +325,16 @@ async function getVerifiedActiveWorkspace() {
326
325
  const config = getConfig();
327
326
  const activeWorkspaceId = config.activeWorkspaceId || config.workspaceId;
328
327
  if (!activeWorkspaceId) {
329
- throw new Error("No active workspace selected. Run list_workspaces then set_active_workspace for the exact workspace whose Sellable DNC list should be read or updated.");
328
+ throw new Error("No active workspace selected. Run list_workspaces then set_active_workspace before importing DNC entries.");
330
329
  }
331
330
  const api = getApi();
332
331
  const { workspaces } = await api.get("/api/v3/workspaces");
333
332
  const match = workspaces.find((workspace) => workspace.id === activeWorkspaceId);
334
333
  if (!match) {
335
- throw new Error(`Active workspace ${activeWorkspaceId} is not in the server access list. Run list_workspaces then set_active_workspace for the exact workspace whose Sellable DNC list should be read or updated.`);
334
+ throw new Error(`Active workspace ${activeWorkspaceId} is not in the server access list. Run list_workspaces then set_active_workspace for the workspace that should receive the DNC import.`);
336
335
  }
337
336
  return match;
338
337
  }
339
- function clampListPositiveInt(value, fallback, max) {
340
- const parsed = typeof value === "number"
341
- ? value
342
- : Number.parseInt(String(value ?? ""), 10);
343
- if (!Number.isFinite(parsed) || parsed < 1)
344
- return fallback;
345
- return Math.min(Math.floor(parsed), max);
346
- }
347
- function normalizeDncSourceType(value) {
348
- if (value === undefined || value === null || value === "")
349
- return "all";
350
- if (value === "all" || value === "manual" || value === "hubspot") {
351
- return value;
352
- }
353
- throw new Error("sourceType must be one of all, manual, or hubspot.");
354
- }
355
- function optionalTrimmed(value) {
356
- return typeof value === "string" && value.trim() ? value.trim() : null;
357
- }
358
- function normalizeCreatedAt(value) {
359
- if (value instanceof Date)
360
- return value.toISOString();
361
- return optionalTrimmed(value);
362
- }
363
- function compactDncListEntry(entry) {
364
- const domain = optionalTrimmed(entry.domain);
365
- const linkedin = optionalTrimmed(entry.normalizedLinkedinUrl);
366
- const sourceType = optionalTrimmed(entry.sourceType) ?? "manual";
367
- return {
368
- id: optionalTrimmed(entry.id),
369
- type: domain ? "domain" : "linkedin",
370
- domain,
371
- linkedin,
372
- linkedinUsername: optionalTrimmed(entry.linkedinUsername),
373
- name: optionalTrimmed(entry.name),
374
- source: {
375
- type: sourceType,
376
- label: optionalTrimmed(entry.sourceLabel) ??
377
- (sourceType === "hubspot" ? "HubSpot" : "Manual"),
378
- listName: optionalTrimmed(entry.sourceListName),
379
- },
380
- createdAt: normalizeCreatedAt(entry.createdAt),
381
- };
382
- }
383
- function buildDncListPath(input) {
384
- const page = clampListPositiveInt(input.page, 1, Number.MAX_SAFE_INTEGER);
385
- const limit = clampListPositiveInt(input.limit, 25, MAX_DNC_LIST_LIMIT);
386
- const sourceType = normalizeDncSourceType(input.sourceType);
387
- const params = new URLSearchParams({
388
- page: String(page),
389
- limit: String(limit),
390
- });
391
- const search = optionalTrimmed(input.search);
392
- const listName = optionalTrimmed(input.listName);
393
- if (search)
394
- params.set("search", search);
395
- if (listName)
396
- params.set("listName", listName);
397
- if (sourceType !== "all")
398
- params.set("sourceType", sourceType);
399
- if (input.includeDeleted === true)
400
- params.set("includeDeleted", "true");
401
- return {
402
- path: `/api/v3/dnc-list?${params.toString()}`,
403
- page,
404
- limit,
405
- };
406
- }
407
338
  function makeSignature(payload) {
408
339
  return createHmac("sha256", confirmationSecret)
409
340
  .update(JSON.stringify(payload))
@@ -625,29 +556,6 @@ async function runDncSpotChecks(entries) {
625
556
  }
626
557
  return checks;
627
558
  }
628
- export async function listDncEntries(input = {}) {
629
- const workspace = await getVerifiedActiveWorkspace();
630
- const api = getApi();
631
- const request = buildDncListPath(input);
632
- const response = await api.get(request.path);
633
- const pagination = response.pagination ?? {};
634
- return {
635
- ok: true,
636
- workspaceId: workspace.id,
637
- workspaceName: workspace.name,
638
- pagination: {
639
- total: pagination.total ?? 0,
640
- page: pagination.page ?? request.page,
641
- limit: pagination.limit ?? request.limit,
642
- totalPages: pagination.totalPages ?? 0,
643
- },
644
- listNames: Array.isArray(response.listNames) ? response.listNames : [],
645
- entries: Array.isArray(response.entries)
646
- ? response.entries.map(compactDncListEntry)
647
- : [],
648
- guidance: "This is Sellable's workspace DNC list used by DNC Check.",
649
- };
650
- }
651
559
  export async function loadCsvDncEntries(input) {
652
560
  const workspace = await getVerifiedActiveWorkspace();
653
561
  const api = getApi();
@@ -7,6 +7,7 @@ export type EngagementPost = {
7
7
  name: string;
8
8
  headline: string;
9
9
  profileUrl: string;
10
+ followerCount: number | null;
10
11
  };
11
12
  engagement: {
12
13
  likes: number;
@@ -14,6 +15,16 @@ export type EngagementPost = {
14
15
  shares: number;
15
16
  total: number;
16
17
  };
18
+ reachSignals: {
19
+ targetFollowerMin: number | null;
20
+ targetFollowerMax: number | null;
21
+ followerBandFit: "in_target_band" | "below_target_band" | "above_target_band" | "unknown";
22
+ weightedEngagement: number;
23
+ engagementPer1kFollowers: number | null;
24
+ weightedEngagementPer1kFollowers: number | null;
25
+ reachPenaltyMultiplier: number;
26
+ reachAdjustedScore: number | null;
27
+ };
17
28
  contentPreview: string;
18
29
  };
19
30
  export type SearchEngagementPostsInput = {
@@ -22,6 +33,8 @@ export type SearchEngagementPostsInput = {
22
33
  maxAgeDays?: number;
23
34
  minTotalEngagement?: number;
24
35
  maxPosts?: number;
36
+ targetFollowerMin?: number;
37
+ targetFollowerMax?: number;
25
38
  excludePostUrls?: string[];
26
39
  };
27
40
  export type SearchEngagementPostsResponse = {
@@ -63,6 +76,14 @@ export declare const engageDiscoveryToolDefinitions: {
63
76
  type: string;
64
77
  description: string;
65
78
  };
79
+ targetFollowerMin: {
80
+ type: string;
81
+ description: string;
82
+ };
83
+ targetFollowerMax: {
84
+ type: string;
85
+ description: string;
86
+ };
66
87
  excludePostUrls: {
67
88
  type: string;
68
89
  items: {
@@ -27,6 +27,14 @@ export const engageDiscoveryToolDefinitions = [
27
27
  type: "number",
28
28
  description: "Max posts to return after filtering (default 25).",
29
29
  },
30
+ targetFollowerMin: {
31
+ type: "number",
32
+ description: "Optional lower bound for the author's follower count when comparing reach-normalized hook performance. Does not hard-filter; adds reach signals and prioritizes the target band when follower data is available.",
33
+ },
34
+ targetFollowerMax: {
35
+ type: "number",
36
+ description: "Optional upper bound for the author's follower count when comparing reach-normalized hook performance. Does not hard-filter; adds reach signals and prioritizes the target band when follower data is available.",
37
+ },
30
38
  excludePostUrls: {
31
39
  type: "array",
32
40
  items: { type: "string" },
@@ -63,6 +71,101 @@ function safeNumber(value) {
63
71
  const n = typeof value === "number" ? value : Number(value);
64
72
  return Number.isFinite(n) ? n : 0;
65
73
  }
74
+ function parseFollowerCount(value) {
75
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
76
+ return Math.round(value);
77
+ }
78
+ if (typeof value !== "string")
79
+ return null;
80
+ const cleaned = value.trim().toLowerCase().replace(/,/g, "");
81
+ if (!cleaned)
82
+ return null;
83
+ const match = cleaned.match(/(\d+(?:\.\d+)?)\s*([kmb])?/);
84
+ if (!match)
85
+ return null;
86
+ const base = Number(match[1]);
87
+ if (!Number.isFinite(base) || base <= 0)
88
+ return null;
89
+ const suffix = match[2] || "";
90
+ const multiplier = suffix === "b" ? 1_000_000_000 : suffix === "m" ? 1_000_000 : suffix === "k" ? 1_000 : 1;
91
+ return Math.round(base * multiplier);
92
+ }
93
+ function extractFollowerCount(post) {
94
+ const candidates = [
95
+ post?.author?.followerCount,
96
+ post?.author?.followersCount,
97
+ post?.author?.followers,
98
+ post?.author?.follower_count,
99
+ post?.author?.linkedinFollowers,
100
+ post?.author?.stats?.followers,
101
+ post?.followerCount,
102
+ post?.followersCount,
103
+ post?.followers,
104
+ post?.authorFollowerCount,
105
+ ];
106
+ for (const candidate of candidates) {
107
+ const parsed = parseFollowerCount(candidate);
108
+ if (parsed !== null)
109
+ return parsed;
110
+ }
111
+ return null;
112
+ }
113
+ function normalizeFollowerBound(value) {
114
+ const parsed = parseFollowerCount(value);
115
+ return parsed && parsed > 0 ? parsed : null;
116
+ }
117
+ function followerBandFit(followerCount, targetFollowerMin, targetFollowerMax) {
118
+ if (!followerCount)
119
+ return "unknown";
120
+ if (targetFollowerMin && followerCount < targetFollowerMin) {
121
+ return "below_target_band";
122
+ }
123
+ if (targetFollowerMax && followerCount > targetFollowerMax) {
124
+ return "above_target_band";
125
+ }
126
+ if (targetFollowerMin || targetFollowerMax)
127
+ return "in_target_band";
128
+ return "unknown";
129
+ }
130
+ function bandMultiplier(fit, followerCount, targetFollowerMax) {
131
+ if (fit === "in_target_band")
132
+ return 1;
133
+ if (fit === "below_target_band")
134
+ return 0.75;
135
+ if (fit === "unknown")
136
+ return 0.4;
137
+ if (!followerCount || !targetFollowerMax)
138
+ return 0.45;
139
+ if (followerCount <= targetFollowerMax * 2)
140
+ return 0.65;
141
+ if (followerCount <= targetFollowerMax * 5)
142
+ return 0.35;
143
+ return 0.15;
144
+ }
145
+ function reachSignals(engagement, followerCount, targetFollowerMin, targetFollowerMax) {
146
+ const weightedEngagement = engagement.likes + engagement.comments * 4 + engagement.shares * 12;
147
+ const fit = followerBandFit(followerCount, targetFollowerMin, targetFollowerMax);
148
+ const engagementPer1kFollowers = followerCount
149
+ ? Number(((engagement.total / followerCount) * 1000).toFixed(3))
150
+ : null;
151
+ const weightedEngagementPer1kFollowers = followerCount
152
+ ? Number(((weightedEngagement / followerCount) * 1000).toFixed(3))
153
+ : null;
154
+ const reachPenaltyMultiplier = bandMultiplier(fit, followerCount, targetFollowerMax);
155
+ const reachAdjustedScore = weightedEngagementPer1kFollowers === null
156
+ ? null
157
+ : Number((weightedEngagementPer1kFollowers * reachPenaltyMultiplier).toFixed(3));
158
+ return {
159
+ targetFollowerMin,
160
+ targetFollowerMax,
161
+ followerBandFit: fit,
162
+ weightedEngagement,
163
+ engagementPer1kFollowers,
164
+ weightedEngagementPer1kFollowers,
165
+ reachPenaltyMultiplier,
166
+ reachAdjustedScore,
167
+ };
168
+ }
66
169
  export async function searchEngagementPosts(input) {
67
170
  const api = getApi();
68
171
  const keywords = (input.keywords || [])
@@ -79,6 +182,10 @@ export async function searchEngagementPosts(input) {
79
182
  const maxPosts = typeof input.maxPosts === "number" && input.maxPosts > 0
80
183
  ? Math.min(50, input.maxPosts)
81
184
  : 25;
185
+ const targetFollowerMin = normalizeFollowerBound(input.targetFollowerMin);
186
+ const targetFollowerMax = normalizeFollowerBound(input.targetFollowerMax);
187
+ const useTargetFollowerBand = Boolean(targetFollowerMin || targetFollowerMax);
188
+ const collectLimit = useTargetFollowerBand ? 50 : maxPosts;
82
189
  const exclude = new Set((input.excludePostUrls || [])
83
190
  .map((u) => normalizePostUrl(u))
84
191
  .filter(Boolean));
@@ -87,11 +194,13 @@ export async function searchEngagementPosts(input) {
87
194
  keywords: keywords.map((keyword) => ({ keyword })),
88
195
  page,
89
196
  });
90
- const rawPosts = Array.isArray(response?.topPostsForLLM)
91
- ? response.topPostsForLLM
92
- : Array.isArray(response?.posts)
93
- ? response.posts
94
- : [];
197
+ const rawPosts = useTargetFollowerBand && Array.isArray(response?.posts)
198
+ ? response.posts
199
+ : Array.isArray(response?.topPostsForLLM)
200
+ ? response.topPostsForLLM
201
+ : Array.isArray(response?.posts)
202
+ ? response.posts
203
+ : [];
95
204
  const now = Date.now();
96
205
  const oldestMs = now - maxAgeDays * 24 * 60 * 60 * 1000;
97
206
  let tooOld = 0;
@@ -111,6 +220,7 @@ export async function searchEngagementPosts(input) {
111
220
  const comments = safeNumber(p?.engagement?.comments);
112
221
  const shares = safeNumber(p?.engagement?.shares);
113
222
  const total = likes + comments + shares;
223
+ const engagement = { likes, comments, shares, total };
114
224
  if (total < minTotalEngagement) {
115
225
  tooLowEngagement += 1;
116
226
  continue;
@@ -121,6 +231,7 @@ export async function searchEngagementPosts(input) {
121
231
  tooOld += 1;
122
232
  continue;
123
233
  }
234
+ const followerCount = extractFollowerCount(p);
124
235
  kept.push({
125
236
  postId: String(p?.id || ""),
126
237
  url,
@@ -132,15 +243,31 @@ export async function searchEngagementPosts(input) {
132
243
  name: String(p?.author?.name || ""),
133
244
  headline: String(p?.author?.headline || ""),
134
245
  profileUrl: String(p?.author?.profileUrl || ""),
246
+ followerCount,
135
247
  },
136
- engagement: { likes, comments, shares, total },
248
+ engagement,
249
+ reachSignals: reachSignals(engagement, followerCount, targetFollowerMin, targetFollowerMax),
137
250
  contentPreview: previewText(String(p?.content || ""), 220),
138
251
  });
139
- if (kept.length >= maxPosts)
252
+ if (kept.length >= collectLimit)
140
253
  break;
141
254
  }
142
- // Sort by total engagement desc for a predictable shortlist.
143
- kept.sort((a, b) => b.engagement.total - a.engagement.total);
255
+ if (useTargetFollowerBand) {
256
+ kept.sort((a, b) => {
257
+ const scoreDelta = (b.reachSignals.reachAdjustedScore ?? -1) -
258
+ (a.reachSignals.reachAdjustedScore ?? -1);
259
+ if (scoreDelta !== 0)
260
+ return scoreDelta;
261
+ return b.engagement.total - a.engagement.total;
262
+ });
263
+ }
264
+ else {
265
+ // Sort by total engagement desc for a predictable shortlist.
266
+ kept.sort((a, b) => b.engagement.total - a.engagement.total);
267
+ }
268
+ if (kept.length > maxPosts) {
269
+ kept.length = maxPosts;
270
+ }
144
271
  return {
145
272
  success: true,
146
273
  posts: kept,