@sellable/mcp 0.1.113 → 0.1.115

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.
@@ -17,14 +17,22 @@ Use the inherited Sellable MCP tools when available:
17
17
  - `search_signals` to find recent post lanes. Include `campaignOfferId` whenever
18
18
  the parent provides one so selected searches/lists stay attached to the
19
19
  campaign.
20
- - `fetch_post_engagers` to sample engagers from selected posts.
20
+ - `select_promising_posts` to promote the exact posts you will sample into the
21
+ campaign UI before fetching engagers. In campaign-attached runs, do this
22
+ before the first `fetch_post_engagers` call so the user can see which posts
23
+ are being sampled.
24
+ - `fetch_post_engagers` to sample engagers from promoted/selected posts.
21
25
 
22
26
  Process:
23
27
 
24
28
  1. Read the campaign brief, kickoff doc, or lane prompt supplied by the parent.
25
29
  2. Search 3-5 keyword/topic lanes, favoring fresh posts from the last 7-14 days.
26
- 3. Select 3-5 promising posts when available.
27
- 4. Fetch or sample engagers for selected posts and score rough ICP fit from visible headline/display-name cues only. Do not enrich people during viability estimation.
30
+ 3. Select 3-5 promising posts when available. If a `campaignOfferId` was
31
+ supplied, call `select_promising_posts({ campaignOfferId, selectionMode:
32
+ "replace", selections, headlineICPCriteria, currentStep:
33
+ "signal-discovery" })` before sampling so the watched Signal Discovery table
34
+ shows the promoted posts.
35
+ 4. Fetch or sample engagers for promoted posts and score rough ICP fit from visible headline/display-name cues only. Do not enrich people during viability estimation.
28
36
  5. Estimate usable prospects per selected post from sampled pass rate. If the sample is good but volume is low, say how many more similar posts should be added or scraped.
29
37
  6. Return false positives and dead ends explicitly.
30
38
 
package/dist/index-dev.js CHANGED
File without changes
package/dist/index.js CHANGED
File without changes
@@ -25,6 +25,9 @@ export function buildWatchUrl(config, path) {
25
25
  if (workspaceId && !url.searchParams.has("workspaceId")) {
26
26
  url.searchParams.set("workspaceId", workspaceId);
27
27
  }
28
+ if (config.token && !url.searchParams.has("token")) {
29
+ url.searchParams.set("token", config.token);
30
+ }
28
31
  return url.toString();
29
32
  }
30
33
  export function getCampaignBuilderWatchModeParam() {
@@ -81,7 +81,8 @@ export declare function getCampaignContext(input: GetCampaignContextInput): Prom
81
81
  };
82
82
  watchUrl: {
83
83
  urlPresent: boolean;
84
- direct: boolean | undefined;
84
+ direct: boolean;
85
+ autoLogin: boolean;
85
86
  redirectPath: string | null;
86
87
  };
87
88
  table: {
@@ -179,6 +179,7 @@ export function markCampaignContextDirty(campaignId, reason) {
179
179
  watchUrl: {
180
180
  urlPresent: false,
181
181
  direct: false,
182
+ autoLogin: false,
182
183
  redirectPath: null,
183
184
  },
184
185
  table: {
@@ -1047,7 +1047,7 @@ export const leadToolDefinitions = [
1047
1047
  },
1048
1048
  {
1049
1049
  name: "select_promising_posts",
1050
- description: "Select the most promising LinkedIn posts for lead scraping AND provide headline ICP criteria. Use the selectionTarget returned by search_signals (default 3).",
1050
+ description: "Select the most promising LinkedIn posts for lead scraping AND provide headline ICP criteria. Use the selectionTarget returned by search_signals (default 3). In campaign-attached sampling, call select_promising_posts first to promote the sampled posts into the watched UI before fetch_post_engagers.",
1051
1051
  inputSchema: {
1052
1052
  type: "object",
1053
1053
  properties: {
@@ -21,7 +21,7 @@ export const linkedinToolDefinitions = [
21
21
  },
22
22
  {
23
23
  name: "fetch_post_engagers",
24
- description: "Fetch people who engaged with a LinkedIn post (reactions and/or comments). Returns deduplicated engagers with name, headline, profileUrl, and source. Use 'comments' for lead magnets to get people who actively engaged. Uses smart rate limiting and parallel fetching.",
24
+ description: "Fetch people who engaged with a LinkedIn post (reactions and/or comments). Returns deduplicated engagers with name, headline, profileUrl, and source. Use 'comments' for lead magnets to get people who actively engaged. Uses smart rate limiting and parallel fetching. In campaign-attached Signal Discovery sampling, call select_promising_posts first to promote the sampled posts into the watched UI.",
25
25
  inputSchema: {
26
26
  type: "object",
27
27
  properties: {
@@ -81,7 +81,8 @@ export declare function computeCampaignNavigationStateFromCampaign(campaign: Cam
81
81
  };
82
82
  watchUrl: {
83
83
  urlPresent: boolean;
84
- direct: boolean | undefined;
84
+ direct: boolean;
85
+ autoLogin: boolean;
85
86
  redirectPath: string | null;
86
87
  };
87
88
  table: {
@@ -125,7 +126,8 @@ export declare function getCampaignNavigationState(input: GetCampaignNavigationS
125
126
  };
126
127
  watchUrl: {
127
128
  urlPresent: boolean;
128
- direct: boolean | undefined;
129
+ direct: boolean;
130
+ autoLogin: boolean;
129
131
  redirectPath: string | null;
130
132
  };
131
133
  table: {
@@ -385,7 +385,9 @@ function parseWatchUrl(watchUrl, campaignId) {
385
385
  if (!watchUrl) {
386
386
  return {
387
387
  urlPresent: false,
388
+ direct: false,
388
389
  signed: false,
390
+ autoLogin: false,
389
391
  redirectPath: null,
390
392
  warning: null,
391
393
  };
@@ -398,11 +400,15 @@ function parseWatchUrl(watchUrl, campaignId) {
398
400
  url.searchParams.get("mode") === "codex" ||
399
401
  url.searchParams.get("mode") === "watch");
400
402
  if (isDirectCampaignUrl) {
403
+ const autoLogin = Boolean(url.searchParams.get("token"));
401
404
  return {
402
405
  urlPresent: true,
403
406
  direct: true,
407
+ autoLogin,
404
408
  redirectPath: `${url.pathname}?${url.searchParams.toString()}`.replace(/\?$/, ""),
405
- warning: null,
409
+ warning: autoLogin
410
+ ? null
411
+ : "Watch URL is direct but missing token auto-login. Recover a fresh /campaign-builder/{campaignId}?mode={claude|codex}&token=... watch link before asking the user to open it.",
406
412
  };
407
413
  }
408
414
  const redirect = url.searchParams.get("redirect");
@@ -413,18 +419,20 @@ function parseWatchUrl(watchUrl, campaignId) {
413
419
  return {
414
420
  urlPresent: true,
415
421
  direct: false,
422
+ autoLogin: false,
416
423
  redirectPath: decodedRedirect,
417
424
  warning: isLegacySigned
418
- ? "Watch URL still uses a tokenized /auth/continue handoff. Return the safe direct /campaign-builder/{campaignId}?mode={claude|codex} watch URL instead."
419
- : "Watch URL is not a safe direct /campaign-builder/{campaignId}?mode={claude|codex} watch link.",
425
+ ? "Watch URL still uses a tokenized /auth/continue handoff. Return the direct /campaign-builder/{campaignId}?mode={claude|codex}&token=... watch URL instead."
426
+ : "Watch URL is not a direct /campaign-builder/{campaignId}?mode={claude|codex}&token=... watch link.",
420
427
  };
421
428
  }
422
429
  catch {
423
430
  return {
424
431
  urlPresent: true,
425
432
  direct: false,
433
+ autoLogin: false,
426
434
  redirectPath: null,
427
- warning: "Watch URL is malformed; recover a fresh safe direct campaign-builder watch link before browser handoff.",
435
+ warning: "Watch URL is malformed; recover a fresh direct campaign-builder auto-login watch link before handing it to the user.",
428
436
  };
429
437
  }
430
438
  }
@@ -593,6 +601,7 @@ export function computeCampaignNavigationStateFromCampaign(campaign, options) {
593
601
  watchUrl: {
594
602
  urlPresent: watchUrlState.urlPresent,
595
603
  direct: watchUrlState.direct,
604
+ autoLogin: watchUrlState.autoLogin,
596
605
  redirectPath: watchUrlState.redirectPath,
597
606
  },
598
607
  table: {
@@ -134,7 +134,8 @@ export declare function waitForCampaignTableReady(input: WaitForCampaignTableRea
134
134
  };
135
135
  watchUrl: {
136
136
  urlPresent: boolean;
137
- direct: boolean | undefined;
137
+ direct: boolean;
138
+ autoLogin: boolean;
138
139
  redirectPath: string | null;
139
140
  };
140
141
  table: {
@@ -183,7 +184,8 @@ export declare function waitForCampaignTableReady(input: WaitForCampaignTableRea
183
184
  };
184
185
  watchUrl: {
185
186
  urlPresent: boolean;
186
- direct: boolean | undefined;
187
+ direct: boolean;
188
+ autoLogin: boolean;
187
189
  redirectPath: string | null;
188
190
  };
189
191
  table: {
@@ -234,7 +236,8 @@ export declare function waitForCampaignTableReady(input: WaitForCampaignTableRea
234
236
  };
235
237
  watchUrl: {
236
238
  urlPresent: boolean;
237
- direct: boolean | undefined;
239
+ direct: boolean;
240
+ autoLogin: boolean;
238
241
  redirectPath: string | null;
239
242
  };
240
243
  table: {
@@ -204,7 +204,7 @@ export const rubricToolDefinitions = [
204
204
  },
205
205
  {
206
206
  name: "save_rubrics",
207
- description: "Persist rubric criteria to the campaign. Pass leadScoringRubrics directly to save without drafting. Use update_campaign to enable ICP filtering and change steps.",
207
+ description: "Persist rubric criteria to the campaign. Pass leadScoringRubrics directly to save without drafting. Saving active rubrics enables ICP filtering on the campaign so the client can continue to Filter Leads.",
208
208
  inputSchema: {
209
209
  type: "object",
210
210
  properties: {
@@ -6,9 +6,13 @@ function sanitizeWatchUrlValue(value) {
6
6
  if (redirect) {
7
7
  const direct = new URL(decodeURIComponent(redirect), url.origin);
8
8
  const workspaceId = url.searchParams.get("workspaceId");
9
+ const token = url.searchParams.get("token");
9
10
  if (workspaceId && !direct.searchParams.has("workspaceId")) {
10
11
  direct.searchParams.set("workspaceId", workspaceId);
11
12
  }
13
+ if (token && !direct.searchParams.has("token")) {
14
+ direct.searchParams.set("token", token);
15
+ }
12
16
  return direct.toString();
13
17
  }
14
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.113",
3
+ "version": "0.1.115",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -134,6 +134,12 @@ claim they ran in parallel and do not surface install status to the customer. In
134
134
  chat, call the downstream copy stage `message generation`;
135
135
  `message-validation.md` is only an internal proof artifact.
136
136
 
137
+ For campaign-attached Signal Discovery sampling, promote/select the exact posts
138
+ with `select_promising_posts` before `fetch_post_engagers` so the user can see
139
+ which posts are being sampled in the watched app. The watch guide should say
140
+ that we are pulling sample engagers from these posts to confirm the ICP is
141
+ actually engaging and the source is viable.
142
+
137
143
  After find-leads returns a lead source and the user approves it, use the same
138
144
  registry pattern for the two post-lead branches. The create-campaign-v2 subskill
139
145
  calls `get_post_find_leads_scout_registry`, then launches the returned
@@ -159,45 +165,58 @@ linked skill versions, runbooks, npm/package details, repo-local files, VPS or
159
165
  browser automation limitations, or local skill files in normal customer-facing
160
166
  copy.
161
167
 
162
- ## Codex Watch Browser Handoff
163
-
164
- When a campaign tool returns `watchUrl`, treat it as an active browser handoff,
165
- not only a URL to print. A valid handoff link must be a safe direct
166
- `/campaign-builder/{campaignId}?mode=claude` URL. `create_campaign.watchUrl`,
167
- `create_campaign({ campaignId }).watchUrl`, and `get_campaign.watchUrl` are all
168
- acceptable only when they return that direct campaign-builder shape, with
169
- `workspaceId` used only as a safe routing hint when needed.
170
-
171
- In Codex Desktop, when in-app browser control is available, open the returned watch link on the user's behalf. After opening it, inspect the browser-visible campaign state, then explain what the user is seeing before continuing with more campaign tools. Use customer language such as:
172
-
173
- ```text
174
- I’ll open the campaign view and keep it in sync as I build.
175
- ```
176
-
177
- If browser control is unavailable, provide the watch link without discussing the
178
- runtime limitation. In off-desktop Codex runs, make the direct watch link easy
179
- to copy/open, then continue with explicit customer-facing campaign progress and
180
- `get_campaign_navigation_state` orientation checks.
181
- Use this fallback shape:
168
+ ## Watch Link Handoff
169
+
170
+ When a campaign tool returns `watchUrl`, treat it as a user-opened watch link.
171
+ A valid handoff link must be a safe direct
172
+ `/campaign-builder/{campaignId}?mode=claude|codex|watch` URL.
173
+ `create_campaign.watchUrl`, `create_campaign({ campaignId }).watchUrl`, and
174
+ `get_campaign.watchUrl` are all acceptable only when they return that direct
175
+ campaign-builder shape, with `workspaceId` used only as a safe routing hint when
176
+ needed.
177
+
178
+ Do not claim Sellable can force every host to open links in a specific browser.
179
+ The host controls link-click behavior. Do not call Chrome, browser automation,
180
+ shell `open`, Computer Use, or in-app browser-control tools just because a
181
+ watch link exists.
182
+
183
+ - In Codex Desktop, clicking a URL can open the Codex in-app browser, but that
184
+ browser is only reliable for unauthenticated/public pages. Sellable campaign
185
+ watch pages may need a signed-in session. Tell the user to click the link
186
+ first; if it lands on sign-up/auth, they should open the same link in their
187
+ regular browser to watch real-time updates.
188
+ - In Claude Code, provide the link and direct the user to open it in their
189
+ browser. If their Claude Code setup opens links in an in-app/preview browser,
190
+ that is fine, but do not attempt to open Chrome for them.
191
+
192
+ Make the direct watch link easy to copy/open, then continue with explicit
193
+ customer-facing campaign progress and `get_campaign_navigation_state`
194
+ orientation checks. Use this fallback shape:
182
195
 
183
196
  ```text
184
- Watch link: {watchUrl}
197
+ Watch link: [Open campaign]({watchUrl})
185
198
 
186
- I’ll keep the campaign brief, lead source, and message-review steps explicit
187
- here as I build.
199
+ Click that link to watch the campaign. If it opens in an in-app browser and asks
200
+ you to sign in, open the same link in your regular browser so you can watch
201
+ real-time updates from your signed-in session. I’ll keep the brief, lead source,
202
+ and message-review steps explicit here as I build.
188
203
  ```
189
204
 
190
- If opening the watch link lands on an auth, 404, permission, blank, or visible
191
- error state, report only what is visible, recover a fresh watch link once with
192
- `create_campaign({ campaignId })` or `get_campaign`, and try that link. Do not
193
- claim the browser was opened, inspected, or synchronized unless the visible
194
- browser state was actually observed. Do not claim the browser was opened unless
195
- it was opened and observed.
205
+ If the user reports that the watch link lands on auth, 404, permission, blank,
206
+ or an error state, recover a fresh watch link once with
207
+ `create_campaign({ campaignId })` or `get_campaign`, then print the recovered
208
+ link. Do not claim the browser was opened, inspected, or synchronized.
196
209
 
197
210
  After every `update_campaign({ campaignId, currentStep })`, use
198
211
  `get_campaign_navigation_state` when available as a compact orientation check:
199
- match the browser-visible step to the saved campaign state, explain the current
200
- state in one sentence, and only then continue. Sender selection belongs at Settings after message approval and 10-row validation. After message validation, use Settings to help the user connect or select a LinkedIn sender. Explain Slack reply review before launch. After sender selection, attach the recommended sequence and move the watched UI to Send. Do not start the campaign or trigger a live send unless the user explicitly confirms that launch action outside UAT.
212
+ match the expected watch-link step to the saved campaign state, explain the
213
+ current state in one sentence, and only then continue. Sender selection belongs
214
+ at Settings after message approval and 10-row validation. After message
215
+ validation, use Settings to help the user connect or select a LinkedIn sender.
216
+ Explain Slack reply review before launch. After sender selection, attach the
217
+ recommended sequence and move the watched UI to Send. Do not start the campaign
218
+ or trigger a live send unless the user explicitly confirms that launch action
219
+ outside UAT.
201
220
 
202
221
  ## Names To Use
203
222
 
@@ -805,7 +805,9 @@ Required behavior:
805
805
  `lead-review.md` focused on source evidence, not host-agent availability.
806
806
  - Branch A: LinkedIn Engagement / active LinkedIn posts (internal provider:
807
807
  Signals / `signal-discovery`). Search relevant keyword lanes, review
808
- finalist posts, fetch top-post engagers, and estimate warm-fit volume.
808
+ finalist posts, promote the sampled posts with `select_promising_posts`
809
+ before fetching engagers when a campaign shell exists, fetch top-post
810
+ engagers, and estimate warm-fit volume.
809
811
  - Branch B: Sales Nav / title + company filters. Run preview filters, inspect
810
812
  preview rows, and estimate scalable-fit volume.
811
813
  - Branch C: Prospeo Contact / domains only when the campaign has a
@@ -1019,7 +1021,9 @@ lower confidence instead of presenting a precise estimate.
1019
1021
 
1020
1022
  For Signals, default to sampling a few promising posts first rather than trying
1021
1023
  to prove the entire source can scale before the user sees evidence. Pick 3-5
1022
- fresh, high-density posts when available, sample engagers, show fit rate, then
1024
+ fresh, high-density posts when available. In campaign-attached watch runs,
1025
+ promote those posts with `select_promising_posts` before sampling so the user
1026
+ sees which posts are being tested. Then sample engagers, show fit rate, and
1023
1027
  state how many additional posts could be added/scraped if that first sample is
1024
1028
  good but volume is low.
1025
1029
 
@@ -330,11 +330,12 @@
330
330
  "campaignBrief"
331
331
  ],
332
332
  "watchUrlSource": "create_campaign.watchUrl",
333
- "requiredWatchUrlShape": "direct /campaign-builder/{campaignId}?mode={claude|codex} watch URL",
333
+ "requiredWatchUrlShape": "direct /campaign-builder/{campaignId}?mode={claude|codex} watch URL with token auto-login and workspace routing",
334
334
  "codexBrowserHandoff": {
335
- "openWhenAvailable": true,
336
- "inspectBeforeContinuing": true,
337
- "explainVisibleStateBeforeContinuing": true,
335
+ "openWhenAvailable": false,
336
+ "printWatchLinkOnly": true,
337
+ "tellUserCommandEnterOrClick": true,
338
+ "mustNotUseBrowserAutomation": true,
338
339
  "fallbackMustNotClaimInspection": true,
339
340
  "recoverFreshSignedLinkOnceOnOpenFailure": true
340
341
  },
@@ -540,7 +541,7 @@
540
541
  "uploadedDomains": "prospeo"
541
542
  },
542
543
  "when": "before_background_source_scouts",
543
- "rule": "Choose the likely primary visible source lane from source intake, brief preference, or the best first lane the main thread will actually search. Send watchNarration with stage find-leads that says what search was tried or is being tried next, what sample is being checked, and why it helps this campaign. Do this before waiting on background scouts so watch mode never sits on only Pick Provider while source work happens elsewhere."
544
+ "rule": "Choose the likely primary visible source lane from source intake, brief preference, or the best first lane the main thread will actually search. Send watchNarration with stage find-leads that says what search was tried or is being tried next, what sample is being checked, and why it helps this campaign. For Signal Discovery sampling, promote/select the posts with select_promising_posts before fetch_post_engagers so the watched table shows the exact posts being sampled; the guide copy should say Codex is pulling sample engagers from these posts to confirm the ICP is actually engaging and the source is viable. Do this before waiting on background scouts so watch mode never sits on only Pick Provider while source work happens elsewhere."
544
545
  },
545
546
  {
546
547
  "action": "run_first_campaign_attached_source_search",
@@ -67,9 +67,9 @@ Signal Discovery:
67
67
  ```json
68
68
  {
69
69
  "stage": "find-leads",
70
- "headline": "Testing Signal Discovery",
71
- "visibleState": "You are watching Signal Discovery search posts about Claude Code and GTM automation.",
72
- "agentIntent": "Codex is sampling engagers to see if they match the founder/operator campaign brief.",
70
+ "headline": "Testing warm LinkedIn activity",
71
+ "visibleState": "The promoted posts are the ones being sampled now.",
72
+ "agentIntent": "Codex is pulling sample engagers from these posts to confirm the ICP is actually engaging and that this source is viable.",
73
73
  "nextAction": "Review batch"
74
74
  }
75
75
  ```
@@ -11,12 +11,13 @@ gating, and handoff read campaign state first.
11
11
 
12
12
  ## Plumbing Reuse
13
13
 
14
- `create_campaign` already returns a safe direct campaign-builder `watchUrl` on
15
- the response (`CampaignDetail.watchUrl` in
16
- `mcp/sellable/src/tools/campaigns.ts`). V2 does NOT mint a new token, does NOT call
17
- a different route, and does NOT construct the URL locally. Capture whatever
18
- `watchUrl` the existing tool returns and surface it verbatim. The link must
19
- land on the campaign builder without exposing token secrets in the MCP result.
14
+ `create_campaign` already returns a direct campaign-builder `watchUrl` on the
15
+ response (`CampaignDetail.watchUrl` in `mcp/sellable/src/tools/campaigns.ts`).
16
+ The URL includes the auth token and workspace routing needed for browser
17
+ auto-login, then the app exchanges the token through `/auth/continue` and
18
+ cleans it from the browser URL. V2 does NOT mint a new token, does NOT call a
19
+ different route, and does NOT construct the URL locally. Capture whatever
20
+ `watchUrl` the existing tool returns and surface it verbatim.
20
21
 
21
22
  ## Shell-First Link
22
23
 
@@ -37,7 +38,13 @@ You can watch the lead source, filters, and messages fill in from here.
37
38
 
38
39
  No leads import and nothing sends yet.
39
40
 
40
- Watch link: {watchUrl}
41
+ Watch link: [Open campaign]({watchUrl})
42
+
43
+ Click that link to watch the campaign. If it opens in an in-app browser and asks
44
+ you to sign in, open the same link in your regular browser so you can watch
45
+ real-time updates from your signed-in session.
46
+
47
+ Command-Enter or click that link to watch it in Sellable.
41
48
 
42
49
  Cool, let's find leads.
43
50
  ```
@@ -55,6 +62,25 @@ clear:
55
62
  - the brief is already visible in the campaign
56
63
  - import, sequence, and start are still blocked
57
64
 
65
+ ## Browser Choice
66
+
67
+ Do not claim Sellable can force the user's host to open links in a specific
68
+ browser. The host controls link-click behavior.
69
+ Do not call Chrome, browser automation, shell `open`, Computer Use, or in-app
70
+ browser-control tools just because a watch link exists.
71
+
72
+ - Codex Desktop URL clicks can open the Codex in-app browser, but that browser
73
+ is only reliable for public or unauthenticated pages. Sellable campaign watch
74
+ pages depend on a signed-in session, so if the in-app browser lands on
75
+ sign-up/auth, tell the user to open the same link in their regular browser to
76
+ watch real-time updates.
77
+ - Claude Code should provide the link and direct the user to open it in their
78
+ browser. If the user's Claude Code setup opens links in an in-app/preview
79
+ browser, that is fine, but do not attempt to open Chrome for them.
80
+
81
+ Surface the link as a normal Markdown link and continue with explicit progress
82
+ updates in chat.
83
+
58
84
  If shell creation fails or the response is missing `watchUrl`, stop and surface
59
85
  the error. Do not continue into campaignless source scouting in the active
60
86
  shell-first flow.
@@ -93,7 +119,9 @@ cached or reconstructed URL.
93
119
  ## Hard Rules
94
120
 
95
121
  - Never fabricate or reconstruct the URL locally.
96
- - The watch link must be a safe direct campaign-builder URL.
122
+ - The watch link must be a direct campaign-builder URL with token auto-login.
123
+ - Do not call browser-opening tools, shell `open`, Computer Use, or in-app
124
+ browser automation; the user opens the link when they want to watch.
97
125
  - Missing `watchUrl` is an error, not a silent skip.
98
126
  - The first watch link must be shown only after the v1 brief is on the campaign.
99
127
  - A watch link is not approval to import, attach sequence, or start.
@@ -94,6 +94,8 @@ Use conservative logic:
94
94
  1. Start from recent strong-post inventory and sample quality, not raw total post count.
95
95
  2. If the lane looks promising, sample real engagers before claiming a reachable-lead estimate:
96
96
  - choose 1-2 promising posts
97
+ - in campaign-attached runs, call `select_promising_posts` first to promote
98
+ those posts in the watched Signal Discovery UI before sampling engagers
97
99
  - call `fetch_post_engagers`
98
100
  - fetch only a representative first sample, not a full scrape
99
101
  - default to `limit: 25` for one post or `limit: 20` per post when checking two posts
@@ -325,6 +327,11 @@ Select the best posts to scrape AND provide headline ICP criteria.
325
327
  Use `selectionTarget` and `recommendedPostIds` returned by `search_signals`
326
328
  (default selectionTarget is 3).
327
329
 
330
+ For campaign-attached viability sampling, this is also the promote step: call it
331
+ before `fetch_post_engagers` so the user sees the exact posts being sampled in
332
+ the watched Signal Discovery table. Use `selectionMode: "replace"` for the first
333
+ sample set unless the user explicitly wants to add to existing promoted posts.
334
+
328
335
  ```json
329
336
  select_promising_posts({
330
337
  "campaignOfferId": "cmp_xxx",