@sellable/mcp 0.1.365 → 0.1.366

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.
@@ -0,0 +1,465 @@
1
+ import { parse } from "csv-parse";
2
+ import { createReadStream } from "node:fs";
3
+ import { lstat, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { getApi } from "../api.js";
7
+ import { getConfig } from "../auth.js";
8
+ const MAX_FILTER_IDS = 100;
9
+ const MAX_QUERY_LENGTH = 8000;
10
+ export const workspaceExportToolDefinitions = [
11
+ {
12
+ name: "export_workspace_csv",
13
+ description: "Export active-workspace outreach CSVs to local files on the MCP host. " +
14
+ "Use exportIntent to disambiguate counts before exporting: " +
15
+ "`reached_out_people` means one deduped row per unique prospect who received an actual contact attempt (invite, DM, open InMail, or paid/closed InMail) and is the right choice for 'everyone we reached out to'; " +
16
+ "`dashboard_messages` means message events only (DMs + InMails), matching the dashboard Messages Sent card; " +
17
+ "`connections_sent` means invite events only; " +
18
+ "`outreach_event_log` means one row per action event for audit/reconciliation; " +
19
+ "`people_and_events` exports the reached-out people CSV plus matching contact event rows. " +
20
+ "If the user asks for 'sent', 'reached out', or cites conflicting dashboard/export numbers, ask which intent they want before calling the tool. " +
21
+ "Requires a valid Sellable API key plus active workspace; run list_workspaces and set_active_workspace first if needed. " +
22
+ "The manifest includes file paths, counts, filters, package/runtime metadata, and skipped/deferred scope notes. " +
23
+ "CSV files may contain prospect and outreach metadata, so store them appropriately.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ exportType: {
28
+ type: "string",
29
+ enum: ["outreach"],
30
+ description: "Export family. Phase 70 supports outreach only.",
31
+ },
32
+ exportIntent: {
33
+ type: "string",
34
+ enum: [
35
+ "reached_out_people",
36
+ "people_and_events",
37
+ "outreach_event_log",
38
+ "dashboard_messages",
39
+ "connections_sent",
40
+ ],
41
+ description: "Recommended semantic export. Use reached_out_people for 'everyone we reached out to'; dashboard_messages for the dashboard Messages Sent card; connections_sent for invites; outreach_event_log for audit rows; people_and_events for both reached people and matching contact events.",
42
+ },
43
+ datasets: {
44
+ type: "array",
45
+ items: { type: "string", enum: ["people", "events"] },
46
+ description: "Low-level datasets to export. people is one deduped prospect row; events is one action row. Omit this and use exportIntent for clearer behavior.",
47
+ },
48
+ actionTypes: {
49
+ type: "array",
50
+ items: {
51
+ type: "string",
52
+ enum: [
53
+ "INVITE",
54
+ "DM",
55
+ "INMAIL_OPEN",
56
+ "INMAIL_CLOSED",
57
+ "VIEW_PROFILE",
58
+ "COMMENT",
59
+ ],
60
+ },
61
+ description: "Optional low-level action filter. Usually omit and use exportIntent. Dashboard messages are DM + INMAIL_OPEN + INMAIL_CLOSED; reached-out/contact events are INVITE + DM + INMAIL_OPEN + INMAIL_CLOSED.",
62
+ },
63
+ outputDir: {
64
+ type: "string",
65
+ description: "Optional base directory. A unique run subdirectory is created inside it.",
66
+ },
67
+ fromSentAt: {
68
+ type: "string",
69
+ description: "Optional ISO lower bound for outreach sentAt.",
70
+ },
71
+ toSentAt: {
72
+ type: "string",
73
+ description: "Optional ISO upper bound for outreach sentAt.",
74
+ },
75
+ campaignIds: {
76
+ type: "array",
77
+ items: { type: "string" },
78
+ description: "Optional campaign ids to include, max 100.",
79
+ },
80
+ tableIds: {
81
+ type: "array",
82
+ items: { type: "string" },
83
+ description: "Optional workflow table ids to include, max 100.",
84
+ },
85
+ },
86
+ required: [],
87
+ additionalProperties: false,
88
+ },
89
+ },
90
+ ];
91
+ const CONTACT_ACTION_TYPES = [
92
+ "INVITE",
93
+ "DM",
94
+ "INMAIL_OPEN",
95
+ "INMAIL_CLOSED",
96
+ ];
97
+ const DASHBOARD_MESSAGE_ACTION_TYPES = [
98
+ "DM",
99
+ "INMAIL_OPEN",
100
+ "INMAIL_CLOSED",
101
+ ];
102
+ const ALL_ACTION_TYPES = [
103
+ "INVITE",
104
+ "DM",
105
+ "INMAIL_OPEN",
106
+ "INMAIL_CLOSED",
107
+ "VIEW_PROFILE",
108
+ "COMMENT",
109
+ ];
110
+ const EXPORT_INTENT_CONFIG = {
111
+ reached_out_people: {
112
+ datasets: ["people"],
113
+ actionTypes: CONTACT_ACTION_TYPES,
114
+ description: "Unique prospects who received at least one actual contact attempt: invite, DM, open InMail, or paid/closed InMail.",
115
+ },
116
+ people_and_events: {
117
+ datasets: ["people", "events"],
118
+ actionTypes: CONTACT_ACTION_TYPES,
119
+ description: "Reached-out people plus one matching contact event row per invite, DM, open InMail, or paid/closed InMail.",
120
+ },
121
+ outreach_event_log: {
122
+ datasets: ["events"],
123
+ actionTypes: ALL_ACTION_TYPES,
124
+ description: "Raw workflow-table-backed action event log for audit/reconciliation, including profile views and comments when present.",
125
+ },
126
+ dashboard_messages: {
127
+ datasets: ["events"],
128
+ actionTypes: DASHBOARD_MESSAGE_ACTION_TYPES,
129
+ description: "Message events only, matching the dashboard Messages Sent card: DMs + open InMails + paid/closed InMails.",
130
+ },
131
+ connections_sent: {
132
+ datasets: ["events"],
133
+ actionTypes: ["INVITE"],
134
+ description: "Connection invite events only, matching the dashboard Connections Sent sub-count.",
135
+ },
136
+ };
137
+ function assertIsoDate(value, field) {
138
+ if (value === undefined || value === null || value === "")
139
+ return undefined;
140
+ if (typeof value !== "string")
141
+ throw new Error(`${field} must be a string`);
142
+ const date = new Date(value);
143
+ if (!Number.isFinite(date.getTime())) {
144
+ throw new Error(`${field} must be a valid ISO date`);
145
+ }
146
+ return date.toISOString();
147
+ }
148
+ function assertIdList(value, field) {
149
+ if (value === undefined || value === null)
150
+ return [];
151
+ if (!Array.isArray(value))
152
+ throw new Error(`${field} must be an array`);
153
+ if (value.length > MAX_FILTER_IDS) {
154
+ throw new Error(`${field} supports at most ${MAX_FILTER_IDS} ids`);
155
+ }
156
+ return value.map((id) => {
157
+ if (typeof id !== "string" || !/^[A-Za-z0-9_.:-]+$/.test(id)) {
158
+ throw new Error(`${field} contains an invalid id`);
159
+ }
160
+ return id;
161
+ });
162
+ }
163
+ function normalizeDatasets(value) {
164
+ if (value === undefined || value === null)
165
+ return ["people", "events"];
166
+ if (!Array.isArray(value))
167
+ throw new Error("datasets must be an array");
168
+ if (value.length === 0)
169
+ throw new Error("datasets cannot be empty");
170
+ const unique = Array.from(new Set(value));
171
+ return unique.map((dataset) => {
172
+ if (dataset !== "people" && dataset !== "events") {
173
+ throw new Error("datasets may only contain people or events");
174
+ }
175
+ return dataset;
176
+ });
177
+ }
178
+ function normalizeExportIntent(value) {
179
+ if (value === undefined || value === null || value === "")
180
+ return undefined;
181
+ if (value !== "reached_out_people" &&
182
+ value !== "people_and_events" &&
183
+ value !== "outreach_event_log" &&
184
+ value !== "dashboard_messages" &&
185
+ value !== "connections_sent") {
186
+ throw new Error("exportIntent is not supported");
187
+ }
188
+ return value;
189
+ }
190
+ function normalizeActionTypes(value) {
191
+ if (value === undefined || value === null)
192
+ return [];
193
+ if (!Array.isArray(value))
194
+ throw new Error("actionTypes must be an array");
195
+ if (value.length === 0)
196
+ throw new Error("actionTypes cannot be empty");
197
+ const unique = Array.from(new Set(value));
198
+ return unique.map((actionType) => {
199
+ if (actionType !== "INVITE" &&
200
+ actionType !== "DM" &&
201
+ actionType !== "INMAIL_OPEN" &&
202
+ actionType !== "INMAIL_CLOSED" &&
203
+ actionType !== "VIEW_PROFILE" &&
204
+ actionType !== "COMMENT") {
205
+ throw new Error("actionTypes contains an unsupported action type");
206
+ }
207
+ return actionType;
208
+ });
209
+ }
210
+ function validateInput(input) {
211
+ if (input.exportType && input.exportType !== "outreach") {
212
+ throw new Error("exportType must be outreach");
213
+ }
214
+ const exportIntent = normalizeExportIntent(input.exportIntent);
215
+ const intentConfig = exportIntent ? EXPORT_INTENT_CONFIG[exportIntent] : null;
216
+ const fromSentAt = assertIsoDate(input.fromSentAt, "fromSentAt");
217
+ const toSentAt = assertIsoDate(input.toSentAt, "toSentAt");
218
+ if (fromSentAt && toSentAt && new Date(fromSentAt) > new Date(toSentAt)) {
219
+ throw new Error("fromSentAt must be before toSentAt");
220
+ }
221
+ const actionTypes = normalizeActionTypes(input.actionTypes);
222
+ return {
223
+ exportIntent,
224
+ intentDescription: intentConfig?.description ?? null,
225
+ datasets: input.datasets === undefined || input.datasets === null
226
+ ? (intentConfig?.datasets ?? ["people", "events"])
227
+ : normalizeDatasets(input.datasets),
228
+ actionTypes: actionTypes.length > 0 ? actionTypes : (intentConfig?.actionTypes ?? []),
229
+ outputDir: input.outputDir,
230
+ fromSentAt,
231
+ toSentAt,
232
+ campaignIds: assertIdList(input.campaignIds, "campaignIds"),
233
+ tableIds: assertIdList(input.tableIds, "tableIds"),
234
+ };
235
+ }
236
+ function countDefinitions() {
237
+ return {
238
+ reachedOutPeople: "Deduped prospects with at least one actual contact attempt: INVITE, DM, INMAIL_OPEN, or INMAIL_CLOSED. This is what 'everyone we reached out to' should mean.",
239
+ outreachEvents: "One row per exported LinkedInOutreach action event after workspace, date, campaign/table, and action-type filters.",
240
+ dashboardMessagesSent: "Dashboard Messages Sent equals DM + INMAIL_OPEN + INMAIL_CLOSED events. It excludes connection invites.",
241
+ connectionsSent: "Dashboard Connections Sent equals INVITE events. Accepted invites show separately as Connections Made.",
242
+ profileViews: "VIEW_PROFILE is an action event for audit logs, but it is not a contact attempt and should not define reached-out people.",
243
+ };
244
+ }
245
+ async function packageVersion() {
246
+ const entryDir = process.argv[1]
247
+ ? path.dirname(path.resolve(process.argv[1]))
248
+ : process.cwd();
249
+ const candidates = [
250
+ path.resolve(process.cwd(), "mcp/sellable/package.json"),
251
+ path.resolve(entryDir, "../package.json"),
252
+ path.resolve(entryDir, "../../package.json"),
253
+ ];
254
+ for (const candidate of candidates) {
255
+ try {
256
+ const raw = await readFile(candidate, "utf8");
257
+ const parsed = JSON.parse(raw);
258
+ if (typeof parsed.version === "string")
259
+ return parsed.version;
260
+ }
261
+ catch {
262
+ // Try the next runtime/package layout.
263
+ }
264
+ }
265
+ return null;
266
+ }
267
+ async function ensureSafeBaseDir(outputDir) {
268
+ const baseDir = path.resolve(outputDir || path.join(tmpdir(), "sellable-mcp-exports"));
269
+ await mkdir(baseDir, { recursive: true });
270
+ const baseStat = await lstat(baseDir);
271
+ if (baseStat.isSymbolicLink()) {
272
+ throw new Error("outputDir must not be a symlink");
273
+ }
274
+ if (!baseStat.isDirectory()) {
275
+ throw new Error("outputDir must be a directory");
276
+ }
277
+ return baseDir;
278
+ }
279
+ async function createRunDir(outputDir) {
280
+ const baseDir = await ensureSafeBaseDir(outputDir);
281
+ for (let attempt = 0; attempt < 10; attempt++) {
282
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
283
+ const runDir = path.join(baseDir, `workspace-outreach-${stamp}-${process.pid}-${attempt}`);
284
+ try {
285
+ await mkdir(runDir, { recursive: false });
286
+ return runDir;
287
+ }
288
+ catch (error) {
289
+ if (error.code !== "EEXIST")
290
+ throw error;
291
+ }
292
+ }
293
+ throw new Error("Failed to create unique export directory");
294
+ }
295
+ function buildQuery(params) {
296
+ const searchParams = new URLSearchParams();
297
+ for (const [key, value] of Object.entries(params)) {
298
+ if (Array.isArray(value)) {
299
+ if (value.length > 0)
300
+ searchParams.set(key, value.join(","));
301
+ }
302
+ else if (value) {
303
+ searchParams.set(key, value);
304
+ }
305
+ }
306
+ const query = searchParams.toString();
307
+ if (query.length > MAX_QUERY_LENGTH) {
308
+ throw new Error("Export query string is too long");
309
+ }
310
+ return query ? `?${query}` : "";
311
+ }
312
+ async function countCsvRows(filePath) {
313
+ const parser = createReadStream(filePath).pipe(parse({ bom: true, relax_column_count: true }));
314
+ let records = 0;
315
+ for await (const _record of parser) {
316
+ records += 1;
317
+ }
318
+ return Math.max(0, records - 1);
319
+ }
320
+ function redactManifest(value) {
321
+ if (Array.isArray(value))
322
+ return value.map(redactManifest);
323
+ if (!value || typeof value !== "object")
324
+ return value;
325
+ const entries = Object.entries(value).map(([key, entry]) => {
326
+ if (/token|secret|authorization|api[_-]?key/i.test(key)) {
327
+ return [key, "[redacted]"];
328
+ }
329
+ return [key, redactManifest(entry)];
330
+ });
331
+ return Object.fromEntries(entries);
332
+ }
333
+ async function writeJsonAtomic(filePath, value) {
334
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
335
+ await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
336
+ encoding: "utf8",
337
+ flag: "wx",
338
+ });
339
+ await rename(tempPath, filePath);
340
+ }
341
+ export async function exportWorkspaceCsv(input = {}) {
342
+ const config = getConfig();
343
+ const workspaceId = config.activeWorkspaceId || config.workspaceId || null;
344
+ if (!workspaceId) {
345
+ throw new Error("No active workspace selected. Run list_workspaces then set_active_workspace before export_workspace_csv.");
346
+ }
347
+ const validated = validateInput(input);
348
+ const startedAt = new Date().toISOString();
349
+ const runDir = await createRunDir(validated.outputDir);
350
+ const api = getApi();
351
+ const metadataQuery = buildQuery({
352
+ fromSentAt: validated.fromSentAt,
353
+ toSentAt: validated.toSentAt,
354
+ campaignIds: validated.campaignIds,
355
+ tableIds: validated.tableIds,
356
+ actionTypes: validated.actionTypes,
357
+ });
358
+ const metadataEndpoint = `/api/v3/mcp/workspace-export/outreach/metadata${metadataQuery}`;
359
+ const backendMetadata = await api.get(metadataEndpoint);
360
+ const exportCutoff = backendMetadata.exportCutoff;
361
+ if (!exportCutoff) {
362
+ throw new Error("Export metadata did not include exportCutoff");
363
+ }
364
+ const files = [];
365
+ const failures = [];
366
+ for (const dataset of validated.datasets) {
367
+ const query = buildQuery({
368
+ dataset,
369
+ exportCutoff,
370
+ fromSentAt: validated.fromSentAt,
371
+ toSentAt: validated.toSentAt,
372
+ campaignIds: validated.campaignIds,
373
+ tableIds: validated.tableIds,
374
+ actionTypes: validated.actionTypes,
375
+ });
376
+ const endpoint = `/api/v3/mcp/workspace-export/outreach${query}`;
377
+ const filePath = path.join(runDir, `outreach-${dataset}.csv`);
378
+ try {
379
+ const download = await api.downloadToFile(endpoint, filePath);
380
+ files.push({
381
+ dataset,
382
+ path: download.path,
383
+ rows: await countCsvRows(download.path),
384
+ bytes: download.bytes,
385
+ endpoint,
386
+ contentType: download.contentType,
387
+ status: "complete",
388
+ });
389
+ }
390
+ catch (error) {
391
+ const message = error instanceof Error ? error.message : "Unknown error";
392
+ failures.push({ dataset, error: message });
393
+ files.push({
394
+ dataset,
395
+ path: filePath,
396
+ rows: 0,
397
+ bytes: 0,
398
+ endpoint,
399
+ contentType: null,
400
+ status: "failed",
401
+ error: message,
402
+ });
403
+ await rm(filePath, { force: true }).catch(() => undefined);
404
+ }
405
+ }
406
+ const manifest = redactManifest({
407
+ status: failures.length > 0 ? "partial" : "success",
408
+ tool: "export_workspace_csv",
409
+ exportType: "outreach",
410
+ exportIntent: validated.exportIntent ?? "legacy_datasets",
411
+ intentDescription: validated.intentDescription,
412
+ countDefinitions: countDefinitions(),
413
+ startedAt,
414
+ completedAt: new Date().toISOString(),
415
+ package: {
416
+ name: "@sellable/mcp",
417
+ version: await packageVersion(),
418
+ },
419
+ config: {
420
+ apiUrl: config.apiUrl,
421
+ activeWorkspaceId: workspaceId,
422
+ activeWorkspaceName: config.activeWorkspaceName || config.workspaceName,
423
+ },
424
+ backend: {
425
+ metadataEndpoint,
426
+ metadata: backendMetadata,
427
+ },
428
+ filters: {
429
+ fromSentAt: validated.fromSentAt ?? null,
430
+ toSentAt: validated.toSentAt ?? null,
431
+ campaignIds: validated.campaignIds,
432
+ tableIds: validated.tableIds,
433
+ actionTypes: validated.actionTypes,
434
+ exportCutoff,
435
+ },
436
+ outputDir: runDir,
437
+ files,
438
+ failures,
439
+ });
440
+ const manifestPath = path.join(runDir, "manifest.json");
441
+ await writeJsonAtomic(manifestPath, manifest);
442
+ return {
443
+ status: failures.length > 0 ? "partial" : "success",
444
+ outputDir: runDir,
445
+ manifestPath,
446
+ datasets: files.map((file) => ({
447
+ dataset: file.dataset,
448
+ path: file.path,
449
+ rows: file.rows,
450
+ bytes: file.bytes,
451
+ status: file.status,
452
+ })),
453
+ exportCutoff,
454
+ exportIntent: validated.exportIntent ?? null,
455
+ intentDescription: validated.intentDescription,
456
+ countDefinitions: countDefinitions(),
457
+ counts: backendMetadata.counts ?? {},
458
+ workspace: backendMetadata.workspace ?? {
459
+ id: workspaceId,
460
+ name: config.activeWorkspaceName || config.workspaceName || null,
461
+ },
462
+ skipped: backendMetadata.skipped ?? [],
463
+ failures,
464
+ };
465
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.365",
3
+ "version": "0.1.366",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code and Codex campaign workflows",
6
6
  "main": "dist/index.js",
@@ -142,14 +142,6 @@ Treat active fills as capacity-fill preparation: calculate the bounded target
142
142
  from sender capacity when needed, then use the refill workflow to decide source
143
143
  replenishment, enrichment/prep, approval policy, and scheduler proof. Mutation
144
144
  requires exact visible approval and a fresh `get_campaign_refill_state` reread.
145
- For already-running regular campaigns that need Signal Discovery source
146
- replenishment, the refill workflow owns the guarded recovery: clear
147
- `currentStep` only with `clearCurrentStepIfMatches:"running"`, load the
148
- campaign-scoped provider prompt, run campaign-scoped `search_signals`, select
149
- posts with a capacity-target scrape plan, import into the same source path, and
150
- restore Running through `confirm_lead_list`. If the copied rows append beyond
151
- the first table page, inspect `reviewBatch` with table-schema/selector tools and
152
- use adaptive or wider bounded message prep; do not rely on a fixed `maxRowsToCheck:100` pass after source copy.
153
145
  If the user says "prepare/generate X messages", use message-prep primitives with
154
146
  `targetPreparedMessages:X` and default `approvalMode:"mark_ready"`. If the user
155
147
  says "approve X messages", use `approvalMode:"approve"` only for the bounded
@@ -63,15 +63,6 @@ currentStep: "sequence" })` to attach the sender via the v3 senders route and
63
63
  and Paid InMail Campaign is only an explicit paid-InMail opt-in because it
64
64
  can spend InMail credits. If that response does not persist `currentStep:
65
65
  "send"`, call `update_campaign({ campaignId, currentStep: "send" })`.
66
- Then reread `get_campaign({ campaignId })` and `list_tables()` and verify the
67
- current `workflowTableId` has `hasSequence:true`. A campaign-level
68
- `SEQUENCE_EXISTS` response is not enough when `confirm_lead_list` or another
69
- copy step has moved the campaign to a new current campaign table while an old
70
- stale shell table still owns the sequence columns. In that stale-shell case,
71
- either repair only the current workflowTableId with `attach_sequence` using
72
- the same non-paid product template the recommended selector would have chosen,
73
- or stop and report the stale-shell sequence blocker if replacement was not
74
- explicitly approved. Never choose a paid-InMail template for this repair.
75
66
  9. Surface the `handoff.orientation` string from `auto-execute.yaml` and
76
67
  summarize the visible Settings/Sequence/Send state without repeating the
77
68
  watch URL.
@@ -150,11 +141,7 @@ order, atomically:
150
141
  2. **Validate sender + sequence state.** If no sender is attached, refuse start
151
142
  and return the Settings link. If no sequence is attached but a sender is
152
143
  attached, call `attach_recommended_sequence({ campaignId, currentStep:
153
- "send" })` before approving messages, then rerun the precondition check against
154
- the current workflowTableId rather than an older shell table. If
155
- `SEQUENCE_EXISTS` only proves an old shell while the current table lacks
156
- `hasSequence:true`, stop or perform the explicitly approved stale-shell
157
- current-table repair before any launch.
144
+ "send" })` before approving messages.
158
145
  3. **Approve generated messages** through the bounded or broad path:
159
146
  - If the user asked for an exact send count, such as "schedule 250 sends",
160
147
  verify the preparation job approved only the bounded prepared cohort for
@@ -68,7 +68,6 @@ Step 16 — awaiting-user-greenlight
68
68
  ask the user which connected sender to attach; in explicit UAT safe mode only, use the safe mock sender
69
69
  update_campaign(senderIds=[selectedSenderId], currentStep=sequence)
70
70
  attach_recommended_sequence({ campaignId, currentStep: "send" }) # tier-aware: premium/SN -> If Open Profile->INMAIL_OPEN, else INVITE->accepted->DM
71
- reread get_campaign + list_tables; verify hasSequence:true on the current workflowTableId, not an older shell table
72
71
  if the attach response did not move the UI: update_campaign(currentStep=send)
73
72
  surface campaign setup orientation + final launch choices without repeating the watch URL
74
73
  STOP. DO NOT call start_campaign. DO NOT move to running without explicit launch greenlight.
@@ -82,13 +81,7 @@ hand-author a `version: 2` template with nodes, branches, and
82
81
  entryNodeId — that's error-prone mid-long-context (galley-off UAT
83
82
  `20260420T195732Z` failed because Claude hit "Invalid node type" and
84
83
  tried to debug via forbidden Bash/Glob calls). Use `attach_sequence`
85
- only when the caller explicitly needs a custom non-recommended cadence, or when
86
- you have just reread the campaign after `confirm_lead_list` and proved the
87
- current campaign table has no sequence while an older stale shell table is
88
- causing campaign-level `SEQUENCE_EXISTS`. In that stale-shell repair case, use
89
- `attach_sequence` on the current workflowTableId with the same non-paid product
90
- template the backend would select; never choose a paid-InMail template and never
91
- repair a table other than the current workflowTableId.
84
+ only when the caller explicitly needs a custom non-recommended cadence.
92
85
 
93
86
  Hard gates — if you find yourself about to violate any of these, stop
94
87
  first:
@@ -539,14 +532,6 @@ Shape:
539
532
  opt-in because it can spend InMail credits. If the tool response
540
533
  does not persist `currentStep: "send"`, call
541
534
  `update_campaign({ campaignId, currentStep: "send" })`.
542
- Then reread `get_campaign({ campaignId })` and `list_tables()` and verify the
543
- returned current workflowTableId has `hasSequence:true`. If
544
- `attach_recommended_sequence` returned `SEQUENCE_EXISTS` but the current table
545
- still has `hasSequence:false`, do not continue to launch handoff from that
546
- stale shell proof. Repair only the current workflowTableId with
547
- `attach_sequence` using the same non-paid product template the recommended
548
- selector would have chosen, or stop and report the stale-shell sequence
549
- blocker if replacement was not explicitly approved.
550
535
  9. Surface the `handoff.orientation` string from `auto-execute.yaml` without
551
536
  repeating the watch URL.
552
537
  10. Ask the final launch greenlight with the structured question function: