scrapeloop-mcp 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -3
- package/package.json +6 -1
- package/src/index.js +1718 -116
package/src/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
19
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
20
21
|
import {
|
|
21
22
|
CallToolRequestSchema,
|
|
22
23
|
ListToolsRequestSchema,
|
|
@@ -26,6 +27,11 @@ const API_KEY = process.env.SCRAPELOOP_API_KEY;
|
|
|
26
27
|
const BASE = (process.env.SCRAPELOOP_API_URL || 'https://api.scrapeloop.com').replace(/\/$/, '');
|
|
27
28
|
const TIMEOUT_MS = Number(process.env.SCRAPELOOP_TIMEOUT_MS) || 30000;
|
|
28
29
|
const MAX_RETRIES = 3;
|
|
30
|
+
const MAX_TABLE_CELL_WRITES = 500;
|
|
31
|
+
const MAX_TABLE_CELL_REQUEST_BYTES = 256 * 1024;
|
|
32
|
+
const TRACE_HEADER = 'X-Scrapeloop-Trace-Id';
|
|
33
|
+
const SAFE_TRACE_ID = /^[A-Za-z0-9_-]{8,80}$/;
|
|
34
|
+
const STARTUP_WARNINGS = new Set();
|
|
29
35
|
|
|
30
36
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
37
|
|
|
@@ -33,7 +39,23 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
33
39
|
// network errors. Never throws — always resolves to {ok, status, data|error} so a
|
|
34
40
|
// single failed upstream call can't kill the server. The API key is sent in the
|
|
35
41
|
// Authorization header only and is never logged.
|
|
36
|
-
|
|
42
|
+
const uncertainMutationFailure = (status, error, reconciliation) => {
|
|
43
|
+
const base = error && typeof error === 'object' ? error : { detail: String(error) };
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
status,
|
|
47
|
+
error: {
|
|
48
|
+
...base,
|
|
49
|
+
uncertain_result: true,
|
|
50
|
+
retry_guidance:
|
|
51
|
+
reconciliation.retry_guidance ||
|
|
52
|
+
'Do not repeat this mutation yet. The server may have committed it before the response was lost.',
|
|
53
|
+
reconciliation,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function api(method, path, body, options = {}) {
|
|
37
59
|
if (!API_KEY) {
|
|
38
60
|
return {
|
|
39
61
|
ok: false,
|
|
@@ -45,34 +67,55 @@ async function api(method, path, body) {
|
|
|
45
67
|
},
|
|
46
68
|
};
|
|
47
69
|
}
|
|
70
|
+
const fetcher = options.fetcher || fetch;
|
|
71
|
+
const sleepFn = options.sleepFn || sleep;
|
|
72
|
+
const maxRetries = options.maxRetries ?? MAX_RETRIES;
|
|
73
|
+
const shouldRetryResponse =
|
|
74
|
+
options.shouldRetryResponse || ((status) => status === 429 || status >= 500);
|
|
75
|
+
const retryDelay = options.retryDelay || ((attempt) => 500 * 2 ** attempt);
|
|
76
|
+
const traceId = safeTraceId(options.traceId);
|
|
48
77
|
let lastErr;
|
|
49
|
-
for (let attempt = 0; attempt <=
|
|
78
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
50
79
|
let res;
|
|
51
80
|
try {
|
|
52
|
-
res = await
|
|
81
|
+
res = await fetcher(`${BASE}/api/v1${path}`, {
|
|
53
82
|
method,
|
|
54
|
-
headers: {
|
|
83
|
+
headers: {
|
|
84
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
85
|
+
'Content-Type': 'application/json',
|
|
86
|
+
...(traceId ? { [TRACE_HEADER]: traceId } : {}),
|
|
87
|
+
},
|
|
55
88
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
56
89
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
57
90
|
});
|
|
58
91
|
} catch (e) {
|
|
59
92
|
// Network error / timeout — retry a few times, then surface a clean error.
|
|
60
93
|
lastErr = e;
|
|
61
|
-
if (attempt <
|
|
62
|
-
await
|
|
94
|
+
if (attempt < maxRetries) {
|
|
95
|
+
await sleepFn(retryDelay(attempt));
|
|
63
96
|
continue;
|
|
64
97
|
}
|
|
65
98
|
const timedOut = e && (e.name === 'TimeoutError' || e.name === 'AbortError');
|
|
66
|
-
|
|
99
|
+
const failure = {
|
|
67
100
|
ok: false,
|
|
68
101
|
status: 0,
|
|
69
102
|
error: { detail: `Could not reach Scrapeloop (${timedOut ? `timeout after ${TIMEOUT_MS}ms` : String(e)}).` },
|
|
103
|
+
...(options.captureTrace
|
|
104
|
+
? { trace_id: traceId, network_error: timedOut ? 'timeout' : 'transport' }
|
|
105
|
+
: {}),
|
|
70
106
|
};
|
|
107
|
+
return options.reconciliation
|
|
108
|
+
? uncertainMutationFailure(0, failure.error, options.reconciliation)
|
|
109
|
+
: failure;
|
|
71
110
|
}
|
|
72
111
|
// Retry transient upstream failures (rate limit / server errors).
|
|
73
|
-
if ((res.status
|
|
112
|
+
if (shouldRetryResponse(res.status) && attempt < maxRetries) {
|
|
74
113
|
const retryAfter = Number(res.headers.get('retry-after')) * 1000;
|
|
75
|
-
|
|
114
|
+
const delay =
|
|
115
|
+
options.respectRetryAfter !== false && retryAfter > 0
|
|
116
|
+
? retryAfter
|
|
117
|
+
: retryDelay(attempt);
|
|
118
|
+
await sleepFn(delay);
|
|
76
119
|
continue;
|
|
77
120
|
}
|
|
78
121
|
const text = await res.text();
|
|
@@ -84,11 +127,123 @@ async function api(method, path, body) {
|
|
|
84
127
|
}
|
|
85
128
|
if (!res.ok) {
|
|
86
129
|
// Surface the API's structured error verbatim (401 auth, 402 credits, 403 scope, …).
|
|
87
|
-
|
|
130
|
+
if (res.status >= 500 && options.reconciliation) {
|
|
131
|
+
return uncertainMutationFailure(res.status, data, options.reconciliation);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
status: res.status,
|
|
136
|
+
error: data,
|
|
137
|
+
...(options.captureTrace ? { trace_id: responseTraceId(res, traceId) } : {}),
|
|
138
|
+
};
|
|
88
139
|
}
|
|
89
|
-
return {
|
|
140
|
+
return {
|
|
141
|
+
ok: true,
|
|
142
|
+
status: res.status,
|
|
143
|
+
data,
|
|
144
|
+
...(options.captureTrace ? { trace_id: responseTraceId(res, traceId) } : {}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
status: 0,
|
|
150
|
+
error: { detail: `Request failed: ${String(lastErr)}` },
|
|
151
|
+
...(options.captureTrace ? { trace_id: traceId } : {}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const safeTraceId = (value) =>
|
|
156
|
+
typeof value === 'string' && SAFE_TRACE_ID.test(value) ? value : undefined;
|
|
157
|
+
|
|
158
|
+
const responseTraceId = (response, fallback) =>
|
|
159
|
+
safeTraceId(response.headers.get(TRACE_HEADER)) || fallback;
|
|
160
|
+
|
|
161
|
+
const createPreflightTraceId = () => `mcp_${randomUUID()}`;
|
|
162
|
+
|
|
163
|
+
const preflightRetryDelay = (random) =>
|
|
164
|
+
150 + Math.floor(Math.max(0, Math.min(1, random())) * 150);
|
|
165
|
+
|
|
166
|
+
function classifyApiKeyPreflight(response) {
|
|
167
|
+
const traceId = safeTraceId(response.trace_id);
|
|
168
|
+
if (response.ok) {
|
|
169
|
+
return { state: 'authenticated', conclusive: true, status: response.status, traceId };
|
|
170
|
+
}
|
|
171
|
+
if (response.status === 401) {
|
|
172
|
+
return { state: 'invalid_key', conclusive: true, status: 401, traceId };
|
|
173
|
+
}
|
|
174
|
+
if (response.status === 403) {
|
|
175
|
+
return { state: 'forbidden', conclusive: true, status: 403, traceId };
|
|
176
|
+
}
|
|
177
|
+
if (response.status === 429) {
|
|
178
|
+
return { state: 'rate_limited', conclusive: false, status: 429, traceId };
|
|
179
|
+
}
|
|
180
|
+
if (response.status >= 500) {
|
|
181
|
+
return { state: 'server_error', conclusive: false, status: response.status, traceId };
|
|
182
|
+
}
|
|
183
|
+
if (response.status === 0) {
|
|
184
|
+
return {
|
|
185
|
+
state: 'network_unreachable',
|
|
186
|
+
conclusive: false,
|
|
187
|
+
status: 0,
|
|
188
|
+
traceId,
|
|
189
|
+
networkError: response.network_error === 'timeout' ? 'timeout' : 'transport',
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return { state: 'inconclusive', conclusive: false, status: response.status, traceId };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function runApiKeyPreflight(options = {}) {
|
|
196
|
+
const random = options.random || Math.random;
|
|
197
|
+
const traceId = safeTraceId(options.traceId) || createPreflightTraceId();
|
|
198
|
+
let response;
|
|
199
|
+
try {
|
|
200
|
+
response = await api('GET', '/me', undefined, {
|
|
201
|
+
captureTrace: true,
|
|
202
|
+
fetcher: options.fetcher,
|
|
203
|
+
maxRetries: 1,
|
|
204
|
+
respectRetryAfter: false,
|
|
205
|
+
retryDelay: () => preflightRetryDelay(random),
|
|
206
|
+
shouldRetryResponse: (status) => status >= 500,
|
|
207
|
+
sleepFn: options.sleepFn,
|
|
208
|
+
traceId,
|
|
209
|
+
});
|
|
210
|
+
} catch {
|
|
211
|
+
response = {
|
|
212
|
+
ok: false,
|
|
213
|
+
status: 0,
|
|
214
|
+
trace_id: traceId,
|
|
215
|
+
network_error: 'transport',
|
|
216
|
+
};
|
|
90
217
|
}
|
|
91
|
-
return
|
|
218
|
+
return classifyApiKeyPreflight(response);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function formatApiKeyPreflightWarning(response) {
|
|
222
|
+
const trace = response.traceId ? ` Trace ID: ${response.traceId}.` : '';
|
|
223
|
+
if (response.state === 'invalid_key') {
|
|
224
|
+
return `scrapeloop-mcp: API key preflight failed: the key is invalid or revoked. Tools may return authentication errors.${trace}`;
|
|
225
|
+
}
|
|
226
|
+
if (response.state === 'forbidden') {
|
|
227
|
+
return `scrapeloop-mcp: API key preflight failed: the identity check is forbidden. Tool calls remain independent.${trace}`;
|
|
228
|
+
}
|
|
229
|
+
if (response.state === 'rate_limited') {
|
|
230
|
+
return `scrapeloop-mcp: API key preflight inconclusive: the identity check was rate limited. Tool calls remain available.${trace}`;
|
|
231
|
+
}
|
|
232
|
+
if (response.state === 'server_error') {
|
|
233
|
+
return `scrapeloop-mcp: API key preflight inconclusive: the identity check returned server status ${response.status}. Tool calls remain available.${trace}`;
|
|
234
|
+
}
|
|
235
|
+
if (response.state === 'network_unreachable') {
|
|
236
|
+
const kind = response.networkError === 'timeout' ? 'timed out' : 'hit a network error';
|
|
237
|
+
return `scrapeloop-mcp: API key preflight inconclusive: the identity check ${kind}. This does not prove the API is unreachable. Tool calls remain available.${trace}`;
|
|
238
|
+
}
|
|
239
|
+
return `scrapeloop-mcp: API key preflight inconclusive (status ${response.status}). Tool calls remain available.${trace}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function warnStartupOnce(key, message, logger = console.error) {
|
|
243
|
+
if (STARTUP_WARNINGS.has(key)) return false;
|
|
244
|
+
STARTUP_WARNINGS.add(key);
|
|
245
|
+
logger(message);
|
|
246
|
+
return true;
|
|
92
247
|
}
|
|
93
248
|
|
|
94
249
|
const result = (payload) => ({
|
|
@@ -116,6 +271,16 @@ const B = { type: 'boolean' };
|
|
|
116
271
|
const O = { type: 'object' };
|
|
117
272
|
const ARR = (items) => ({ type: 'array', items });
|
|
118
273
|
|
|
274
|
+
// card run-scope-menu: build the run/estimate body from the scope args
|
|
275
|
+
// (view/selection + cell_filter + n_rows/start_row window).
|
|
276
|
+
const runScopeBody = (a) => ({
|
|
277
|
+
...(a.view_id ? { view_id: a.view_id } : {}),
|
|
278
|
+
...(a.selection ? { selection: a.selection } : {}),
|
|
279
|
+
...(a.cell_filter ? { cell_filter: a.cell_filter } : {}),
|
|
280
|
+
...(a.n_rows ? { row_window: { n: a.n_rows, start: a.start_row || 1 } } : {}),
|
|
281
|
+
...(a.only_failed !== undefined ? { only_failed: !!a.only_failed } : {}),
|
|
282
|
+
});
|
|
283
|
+
|
|
119
284
|
// --- Tool registry: name → { def, run } -------------------------------------
|
|
120
285
|
const TOOLS = {
|
|
121
286
|
// ── Discovery / status ────────────────────────────────────────────────
|
|
@@ -145,7 +310,7 @@ const TOOLS = {
|
|
|
145
310
|
get_meta: {
|
|
146
311
|
def: {
|
|
147
312
|
description:
|
|
148
|
-
'List the canonical values for a lead-database filter (seniorities, functions, email_quality_labels,
|
|
313
|
+
'List the canonical values for a lead-database filter (seniorities, functions, email_quality_labels, industries) so search inputs are valid. Use "_all" for everything.',
|
|
149
314
|
inputSchema: obj({ filter: { ...S, description: 'filter name or "_all"' } }, ['filter']),
|
|
150
315
|
},
|
|
151
316
|
run: (a) => api('GET', `/meta/${enc(a.filter)}`),
|
|
@@ -288,15 +453,23 @@ const TOOLS = {
|
|
|
288
453
|
search_leads: {
|
|
289
454
|
def: {
|
|
290
455
|
description:
|
|
291
|
-
'Search the Scrapeloop B2B lead database. Emails are masked until revealed. Pass a filters object (
|
|
456
|
+
'Search the Scrapeloop B2B lead database (people-level). Emails are masked until revealed. Pass a filters object (job_titles_include, job_titles_mode: "contains" (default) | "exact" | "similar", seniorities, functions, countries, states, cities, company_keywords_include, company_names, domains_include, industries, decision_maker, email_quality_labels, ...). Industries are coarse inferred sector buckets from the company name, not verified firmographics. Company size and revenue filters (employee_ranges, revenue_ranges, and related fields) are deprecated no-ops; fold size hints into company_keywords_include. Get valid slugs from get_meta("industries"). Free, costs no credits.',
|
|
292
457
|
inputSchema: obj({ filters: O, limit: { ...N, description: '1-100 (default 25)' }, offset: N }),
|
|
293
458
|
},
|
|
294
459
|
run: (a) => api('POST', '/leads/search', { filters: a.filters || {}, limit: a.limit || 25, offset: a.offset || 0 }),
|
|
295
460
|
},
|
|
296
461
|
estimate_leads: {
|
|
297
|
-
def: { description: 'Count how many leads match a filters object (free).', inputSchema: obj({ filters: O }) },
|
|
462
|
+
def: { description: 'Count how many leads match a filters object (free). Supports job_titles_mode: "contains" (default) | "exact" | "similar".', inputSchema: obj({ filters: O }) },
|
|
298
463
|
run: (a) => api('POST', '/leads/estimate', { filters: a.filters || {} }),
|
|
299
464
|
},
|
|
465
|
+
expand_job_titles: {
|
|
466
|
+
def: {
|
|
467
|
+
description:
|
|
468
|
+
'Preview what "is similar to" title matching will expand to (curated synonym map). Free.',
|
|
469
|
+
inputSchema: obj({ titles: ARR(S) }, ['titles']),
|
|
470
|
+
},
|
|
471
|
+
run: (a) => api('POST', '/leads/title-expansion', { titles: a.titles }),
|
|
472
|
+
},
|
|
300
473
|
reveal_lead: {
|
|
301
474
|
def: {
|
|
302
475
|
description: "Reveal a lead's email by global_person_id. SPENDS 1 lead credit (idempotent — already-revealed leads are free).",
|
|
@@ -311,6 +484,74 @@ const TOOLS = {
|
|
|
311
484
|
},
|
|
312
485
|
run: (a) => api('POST', '/leads/save', { global_person_ids: a.global_person_ids }),
|
|
313
486
|
},
|
|
487
|
+
estimate_lead_import: {
|
|
488
|
+
def: {
|
|
489
|
+
description:
|
|
490
|
+
'Preview a Lead Database to table import: how many match, how many are new reveals, and the lead-credit cost. Free.',
|
|
491
|
+
inputSchema: obj(
|
|
492
|
+
{
|
|
493
|
+
filters: O,
|
|
494
|
+
total_limit: N,
|
|
495
|
+
per_company_limit: N,
|
|
496
|
+
},
|
|
497
|
+
['filters'],
|
|
498
|
+
),
|
|
499
|
+
},
|
|
500
|
+
run: (a) =>
|
|
501
|
+
api('POST', '/leads/import-estimate', {
|
|
502
|
+
filters: a.filters || {},
|
|
503
|
+
...(a.total_limit ? { total_limit: a.total_limit } : {}),
|
|
504
|
+
...(a.per_company_limit ? { per_company_limit: a.per_company_limit } : {}),
|
|
505
|
+
}),
|
|
506
|
+
},
|
|
507
|
+
import_leads_to_table: {
|
|
508
|
+
def: {
|
|
509
|
+
description:
|
|
510
|
+
'Import Lead Database people into a table by filters. SPENDS lead credits for newly revealed people. Exactly one of list_id or new_list_name is required.',
|
|
511
|
+
inputSchema: obj(
|
|
512
|
+
{
|
|
513
|
+
filters: O,
|
|
514
|
+
total_limit: N,
|
|
515
|
+
per_company_limit: N,
|
|
516
|
+
list_id: S,
|
|
517
|
+
new_list_name: S,
|
|
518
|
+
},
|
|
519
|
+
['filters'],
|
|
520
|
+
),
|
|
521
|
+
},
|
|
522
|
+
run: (a) =>
|
|
523
|
+
api('POST', '/leads/import-to-table', {
|
|
524
|
+
filters: a.filters || {},
|
|
525
|
+
...(a.total_limit ? { total_limit: a.total_limit } : {}),
|
|
526
|
+
...(a.per_company_limit ? { per_company_limit: a.per_company_limit } : {}),
|
|
527
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
528
|
+
...(a.new_list_name ? { new_list_name: a.new_list_name } : {}),
|
|
529
|
+
}),
|
|
530
|
+
},
|
|
531
|
+
list_saved_searches: {
|
|
532
|
+
def: {
|
|
533
|
+
description:
|
|
534
|
+
'List saved + recent Lead Database searches (name, filters object, result count, last used). Reuse a search by passing its filters to search_leads / estimate_leads (free).',
|
|
535
|
+
inputSchema: obj({}),
|
|
536
|
+
},
|
|
537
|
+
run: () => api('GET', '/leads/saved-searches'),
|
|
538
|
+
},
|
|
539
|
+
save_search: {
|
|
540
|
+
def: {
|
|
541
|
+
description:
|
|
542
|
+
'Save a Lead Database filters object as a named search for the workspace (free). Upserts by name.',
|
|
543
|
+
inputSchema: obj(
|
|
544
|
+
{ name: S, filters: O, result_count_at_save: N },
|
|
545
|
+
['name', 'filters'],
|
|
546
|
+
),
|
|
547
|
+
},
|
|
548
|
+
run: (a) =>
|
|
549
|
+
api('POST', '/leads/saved-searches', {
|
|
550
|
+
name: a.name,
|
|
551
|
+
filters: a.filters || {},
|
|
552
|
+
result_count_at_save: a.result_count_at_save,
|
|
553
|
+
}),
|
|
554
|
+
},
|
|
314
555
|
bulk_verify_leads: {
|
|
315
556
|
def: {
|
|
316
557
|
description: 'Enqueue a background verification pass over saved/DB leads (by lead_ids or global_person_ids).',
|
|
@@ -324,6 +565,50 @@ const TOOLS = {
|
|
|
324
565
|
}),
|
|
325
566
|
},
|
|
326
567
|
|
|
568
|
+
// ── Local Leads: browse first, spend only on reveal or coverage fill ──
|
|
569
|
+
search_local_leads: {
|
|
570
|
+
def: {
|
|
571
|
+
description:
|
|
572
|
+
'Search existing local-business coverage for free. Results keep email and owner fields masked until reveal. Filter keys include search_query, gcids, niche_text, countries, states, cities, postal_codes, rating_min/max, reviews_min/max, has_website, has_phone, has_email, email_status, has_owner_name, platforms, tech_tools, domain_age_max_years, page_count_min/max, site_signals, has_gbp_description, and sort.',
|
|
573
|
+
inputSchema: obj(
|
|
574
|
+
{ filters: O, limit: { ...N, description: '1-100 (default 25)' }, offset: N },
|
|
575
|
+
),
|
|
576
|
+
},
|
|
577
|
+
run: (a) => api('POST', '/local-leads/search', {
|
|
578
|
+
filters: a.filters || {},
|
|
579
|
+
limit: a.limit || 25,
|
|
580
|
+
offset: a.offset || 0,
|
|
581
|
+
}),
|
|
582
|
+
},
|
|
583
|
+
estimate_local_leads: {
|
|
584
|
+
def: {
|
|
585
|
+
description: 'Count matching local businesses in existing coverage for free. Uses the same filters as search_local_leads.',
|
|
586
|
+
inputSchema: obj({ filters: O }),
|
|
587
|
+
},
|
|
588
|
+
run: (a) => api('POST', '/local-leads/estimate', { filters: a.filters || {} }),
|
|
589
|
+
},
|
|
590
|
+
reveal_local_lead: {
|
|
591
|
+
def: {
|
|
592
|
+
description: 'Reveal one local business by place_id. SPENDS 1 lead credit and is idempotent, so an already revealed record is free.',
|
|
593
|
+
inputSchema: obj({ place_id: S }, ['place_id']),
|
|
594
|
+
},
|
|
595
|
+
run: (a) => api('POST', '/local-leads/reveal', { place_id: a.place_id }),
|
|
596
|
+
},
|
|
597
|
+
fill_coverage_gap: {
|
|
598
|
+
def: {
|
|
599
|
+
description: 'Request a live local scrape when existing coverage is thin or stale. MAY SPEND vendor dollars or lead credits. Search and estimate first, show the expected cost, and confirm with the user before calling.',
|
|
600
|
+
inputSchema: obj({ filters: O }, ['filters']),
|
|
601
|
+
},
|
|
602
|
+
run: (a) => api('POST', '/local-leads/fill-gap', { filters: a.filters || {} }),
|
|
603
|
+
},
|
|
604
|
+
list_local_categories: {
|
|
605
|
+
def: {
|
|
606
|
+
description: 'Resolve a local-business category or synonym to ranked Google category IDs. Use this before search_local_leads when the user gives a plain-language niche.',
|
|
607
|
+
inputSchema: obj({ q: S, country: S }, ['q']),
|
|
608
|
+
},
|
|
609
|
+
run: (a) => api('GET', `/geo/categories${qs({ q: a.q, country: a.country })}`),
|
|
610
|
+
},
|
|
611
|
+
|
|
327
612
|
// ── Step 4: verification ──────────────────────────────────────────────
|
|
328
613
|
verify_email: {
|
|
329
614
|
def: { description: 'Verify an email address (1 verify credit).', inputSchema: obj({ email: S }, ['email']) },
|
|
@@ -422,6 +707,33 @@ const TOOLS = {
|
|
|
422
707
|
...(a.cache_settings ? { cache_settings: a.cache_settings } : {}),
|
|
423
708
|
}),
|
|
424
709
|
},
|
|
710
|
+
get_ai_context: {
|
|
711
|
+
def: {
|
|
712
|
+
description: 'Get the workspace AI context (company description, ICP, buyer personas) that seeds every AI feature.',
|
|
713
|
+
inputSchema: obj({}),
|
|
714
|
+
},
|
|
715
|
+
run: () => api('GET', '/ai-context'),
|
|
716
|
+
},
|
|
717
|
+
update_ai_context: {
|
|
718
|
+
def: {
|
|
719
|
+
description: 'Update the workspace AI context. The personas field replaces the whole array.',
|
|
720
|
+
inputSchema: obj({ company_description: S, icp: S, personas: ARR(S), source_domain: S }),
|
|
721
|
+
},
|
|
722
|
+
run: (a) =>
|
|
723
|
+
api('PUT', '/ai-context', {
|
|
724
|
+
...(a.company_description !== undefined ? { company_description: a.company_description } : {}),
|
|
725
|
+
...(a.icp !== undefined ? { icp: a.icp } : {}),
|
|
726
|
+
...(a.personas !== undefined ? { personas: a.personas } : {}),
|
|
727
|
+
...(a.source_domain !== undefined ? { source_domain: a.source_domain } : {}),
|
|
728
|
+
}),
|
|
729
|
+
},
|
|
730
|
+
generate_ai_context: {
|
|
731
|
+
def: {
|
|
732
|
+
description: 'Draft the AI context from a website domain (fetch + AI summarize). Returns a draft, call update_ai_context to save it. Free, no credits.',
|
|
733
|
+
inputSchema: obj({ domain: S }, ['domain']),
|
|
734
|
+
},
|
|
735
|
+
run: (a) => api('POST', '/ai-context/generate', { domain: a.domain }),
|
|
736
|
+
},
|
|
425
737
|
list_strategies: {
|
|
426
738
|
def: { description: 'List the workspace strategy tags (routing tags campaigns filter on).', inputSchema: obj({}) },
|
|
427
739
|
run: () => api('GET', '/strategies'),
|
|
@@ -494,6 +806,30 @@ const TOOLS = {
|
|
|
494
806
|
def: { description: 'List campaigns with per-campaign stats and the bound list name.', inputSchema: obj({}) },
|
|
495
807
|
run: () => api('GET', '/campaigns'),
|
|
496
808
|
},
|
|
809
|
+
preview_add_leads_to_campaign: {
|
|
810
|
+
def: {
|
|
811
|
+
description:
|
|
812
|
+
'FREE preview for pushing a table view or selection into an existing campaign. ALWAYS show the valid, risky, invalid, suppressed, and already-added breakdown before add_leads_to_campaign. scope accepts view_id plus selection {mode: query, q, filter, exclude_ids} or {mode: explicit, ids}.',
|
|
813
|
+
inputSchema: obj({ campaign_id: S, list_id: S, scope: O }, ['campaign_id', 'list_id']),
|
|
814
|
+
},
|
|
815
|
+
run: (a) =>
|
|
816
|
+
api('POST', `/campaigns/${enc(a.campaign_id)}/add-leads/preview`, {
|
|
817
|
+
list_id: a.list_id,
|
|
818
|
+
...(a.scope ? { scope: a.scope } : {}),
|
|
819
|
+
}),
|
|
820
|
+
},
|
|
821
|
+
add_leads_to_campaign: {
|
|
822
|
+
def: {
|
|
823
|
+
description:
|
|
824
|
+
'Push scoped table rows into a live sender campaign, where they may start receiving email. Run preview_add_leads_to_campaign and confirm with the user first. Invalid emails are never added, and risky emails are added only when the campaign allows them.',
|
|
825
|
+
inputSchema: obj({ campaign_id: S, list_id: S, scope: O }, ['campaign_id', 'list_id']),
|
|
826
|
+
},
|
|
827
|
+
run: (a) =>
|
|
828
|
+
api('POST', `/campaigns/${enc(a.campaign_id)}/add-leads`, {
|
|
829
|
+
list_id: a.list_id,
|
|
830
|
+
...(a.scope ? { scope: a.scope } : {}),
|
|
831
|
+
}),
|
|
832
|
+
},
|
|
497
833
|
create_campaign: {
|
|
498
834
|
def: {
|
|
499
835
|
description:
|
|
@@ -576,154 +912,1297 @@ const TOOLS = {
|
|
|
576
912
|
}),
|
|
577
913
|
},
|
|
578
914
|
|
|
579
|
-
// ──
|
|
915
|
+
// ── Legacy Lead Database saved lists ─────────────────────────────────
|
|
580
916
|
get_lists: {
|
|
581
|
-
def: {
|
|
917
|
+
def: {
|
|
918
|
+
description:
|
|
919
|
+
'List saved B2B Lead Database collections. These are NOT campaign-bindable Tables; use get_tables when you need a source_list_id, enrichment columns, or table cells.',
|
|
920
|
+
inputSchema: obj({}),
|
|
921
|
+
},
|
|
582
922
|
run: () => api('GET', '/lists'),
|
|
583
923
|
},
|
|
584
924
|
create_list: {
|
|
585
|
-
def: {
|
|
925
|
+
def: {
|
|
926
|
+
description:
|
|
927
|
+
'Create a saved B2B Lead Database collection for add_list_members. This is NOT a campaign-bindable Table; use create_table for imports, enrichment columns, or campaigns.',
|
|
928
|
+
inputSchema: obj({ name: S, description: S, color: S }, ['name']),
|
|
929
|
+
},
|
|
586
930
|
run: (a) => api('POST', '/lists', { name: a.name, description: a.description, color: a.color }),
|
|
587
931
|
},
|
|
588
932
|
add_list_members: {
|
|
589
933
|
def: { description: 'Add lead-database people to a list by global_person_id (acquires them, dedup-aware).', inputSchema: obj({ list_id: S, global_person_ids: ARR(S) }, ['list_id', 'global_person_ids']) },
|
|
590
934
|
run: (a) => api('POST', `/lists/${enc(a.list_id)}/members`, { global_person_ids: a.global_person_ids }),
|
|
591
935
|
},
|
|
592
|
-
|
|
936
|
+
|
|
937
|
+
// ── Workbench organization ───────────────────────────────────────────
|
|
938
|
+
list_workbooks: {
|
|
593
939
|
def: {
|
|
594
940
|
description:
|
|
595
|
-
'
|
|
596
|
-
inputSchema: obj(
|
|
597
|
-
{
|
|
598
|
-
list_id: S,
|
|
599
|
-
list_name: S,
|
|
600
|
-
records: ARR(O),
|
|
601
|
-
dedupe_by: { ...S, description: '"email" (default) or "external_id"' },
|
|
602
|
-
default_verification_status: S,
|
|
603
|
-
},
|
|
604
|
-
['records'],
|
|
605
|
-
),
|
|
941
|
+
'Read the complete Workbench tree: folders, workbooks in each folder, workbook table order, standalone Tables, and All leads. Use this before organizing or cloning a client workspace.',
|
|
942
|
+
inputSchema: obj({}),
|
|
606
943
|
},
|
|
607
|
-
run: (
|
|
608
|
-
|
|
944
|
+
run: () => api('GET', '/workbench/tree'),
|
|
945
|
+
},
|
|
946
|
+
create_workbook: {
|
|
947
|
+
def: {
|
|
948
|
+
description:
|
|
949
|
+
'Create a workbook to group related Tables as ordered tabs. Pass existing list_ids in the order they should appear, or add tabs later with add_table_to_workbook.',
|
|
950
|
+
inputSchema: obj({ name: S, list_ids: ARR(S) }, ['name']),
|
|
951
|
+
},
|
|
952
|
+
run: (a) => api('POST', '/workbooks', { name: a.name, list_ids: a.list_ids || [] }),
|
|
953
|
+
},
|
|
954
|
+
add_table_to_workbook: {
|
|
955
|
+
def: {
|
|
956
|
+
description:
|
|
957
|
+
'Add one tab to a workbook. Pass list_id to attach an existing standalone Table, or new_table_name to create a new empty Table and attach it. Pass exactly one.',
|
|
958
|
+
inputSchema: obj({ workbook_id: S, list_id: S, new_table_name: S }, ['workbook_id']),
|
|
959
|
+
},
|
|
960
|
+
run: (a) => {
|
|
961
|
+
if (!!a.list_id === !!a.new_table_name) {
|
|
962
|
+
return {
|
|
963
|
+
ok: false,
|
|
964
|
+
status: 400,
|
|
965
|
+
error: { detail: 'Pass exactly one of list_id or new_table_name.' },
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
return api('POST', `/workbooks/${enc(a.workbook_id)}/tables`, {
|
|
609
969
|
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
610
|
-
...(a.
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
614
|
-
}),
|
|
970
|
+
...(a.new_table_name ? { new_table_name: a.new_table_name } : {}),
|
|
971
|
+
});
|
|
972
|
+
},
|
|
615
973
|
},
|
|
616
|
-
|
|
974
|
+
remove_table_from_workbook: {
|
|
617
975
|
def: {
|
|
618
976
|
description:
|
|
619
|
-
'
|
|
977
|
+
'Remove a Table tab from its workbook without deleting the Table or any rows. The Table becomes standalone in the Workbench root.',
|
|
978
|
+
inputSchema: obj({ workbook_id: S, list_id: S }, ['workbook_id', 'list_id']),
|
|
979
|
+
},
|
|
980
|
+
run: (a) => api('DELETE', `/workbooks/${enc(a.workbook_id)}/tables/${enc(a.list_id)}`),
|
|
981
|
+
},
|
|
982
|
+
reorder_workbook_table: {
|
|
983
|
+
def: {
|
|
984
|
+
description:
|
|
985
|
+
'Change one workbook tab position. Read list_workbooks first, then choose a numeric position between neighboring tabs.',
|
|
986
|
+
inputSchema: obj({ workbook_id: S, list_id: S, position: N }, ['workbook_id', 'list_id', 'position']),
|
|
987
|
+
},
|
|
988
|
+
run: (a) => api('PATCH', `/workbooks/${enc(a.workbook_id)}/tables/${enc(a.list_id)}`, { position: a.position }),
|
|
989
|
+
},
|
|
990
|
+
duplicate_workbook: {
|
|
991
|
+
def: {
|
|
992
|
+
description:
|
|
993
|
+
'Clone a template pipeline for a new client. Copies table structure, settings, and views without rows or cell history. Internal routing rules are rewired to the copied Tables; external, campaign, and webhook rules are disabled with review warnings.',
|
|
620
994
|
inputSchema: obj(
|
|
621
995
|
{
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
default_verification_status: S,
|
|
996
|
+
workbook_id: S,
|
|
997
|
+
name: S,
|
|
998
|
+
folder_id: S,
|
|
999
|
+
include_rules: B,
|
|
627
1000
|
},
|
|
628
|
-
['
|
|
1001
|
+
['workbook_id', 'name'],
|
|
629
1002
|
),
|
|
630
1003
|
},
|
|
631
|
-
run: (a) =>
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
|
|
637
|
-
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
638
|
-
}),
|
|
1004
|
+
run: (a) => api('POST', `/workbooks/${enc(a.workbook_id)}/duplicate`, {
|
|
1005
|
+
name: a.name,
|
|
1006
|
+
...(a.folder_id !== undefined ? { folder_id: a.folder_id } : {}),
|
|
1007
|
+
...(a.include_rules !== undefined ? { include_rules: a.include_rules } : {}),
|
|
1008
|
+
}),
|
|
639
1009
|
},
|
|
640
|
-
|
|
1010
|
+
create_folder: {
|
|
641
1011
|
def: {
|
|
642
1012
|
description:
|
|
643
|
-
|
|
644
|
-
inputSchema: obj({
|
|
1013
|
+
'Create a Workbench folder for client, team, or pipeline organization. Then use move_to_folder for workbooks or standalone Tables.',
|
|
1014
|
+
inputSchema: obj({ name: S }, ['name']),
|
|
645
1015
|
},
|
|
646
|
-
run: (a) =>
|
|
647
|
-
a.list_id
|
|
648
|
-
? api('GET', `/lists/${enc(a.list_id)}/leads${qs({ limit: a.limit, offset: a.offset })}`)
|
|
649
|
-
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
1016
|
+
run: (a) => api('POST', '/folders', { name: a.name }),
|
|
650
1017
|
},
|
|
651
|
-
|
|
1018
|
+
move_to_folder: {
|
|
652
1019
|
def: {
|
|
653
1020
|
description:
|
|
654
|
-
'
|
|
655
|
-
inputSchema: obj(
|
|
1021
|
+
'Move exactly one workbook or standalone Table into a folder. Pass folder_id null to move it back to the Workbench root. Tables inside a workbook move with the workbook, not individually.',
|
|
1022
|
+
inputSchema: obj(
|
|
1023
|
+
{
|
|
1024
|
+
folder_id: { anyOf: [S, { type: 'null' }] },
|
|
1025
|
+
workbook_id: S,
|
|
1026
|
+
list_id: S,
|
|
1027
|
+
},
|
|
1028
|
+
['folder_id'],
|
|
1029
|
+
),
|
|
1030
|
+
},
|
|
1031
|
+
run: (a) => {
|
|
1032
|
+
if (!!a.workbook_id === !!a.list_id) {
|
|
1033
|
+
return {
|
|
1034
|
+
ok: false,
|
|
1035
|
+
status: 400,
|
|
1036
|
+
error: { detail: 'Pass exactly one of workbook_id or list_id.' },
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
return a.workbook_id
|
|
1040
|
+
? api('PATCH', `/workbooks/${enc(a.workbook_id)}`, { folder_id: a.folder_id })
|
|
1041
|
+
: api('PATCH', `/lists-placement/${enc(a.list_id)}`, { folder_id: a.folder_id });
|
|
656
1042
|
},
|
|
657
|
-
run: (a) =>
|
|
658
|
-
a.list_id
|
|
659
|
-
? api('DELETE', `/lists/${enc(a.list_id)}`)
|
|
660
|
-
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
661
1043
|
},
|
|
662
|
-
|
|
1044
|
+
delete_folder: {
|
|
663
1045
|
def: {
|
|
664
1046
|
description:
|
|
665
|
-
'Delete
|
|
666
|
-
inputSchema: obj({
|
|
1047
|
+
'Delete an empty or organizational Workbench folder. Its workbooks and standalone Tables move to the root; their data is not deleted.',
|
|
1048
|
+
inputSchema: obj({ folder_id: S }, ['folder_id']),
|
|
667
1049
|
},
|
|
668
|
-
run: (a) =>
|
|
669
|
-
a.campaign_id
|
|
670
|
-
? api('DELETE', `/campaigns/${enc(a.campaign_id)}`)
|
|
671
|
-
: { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
|
|
1050
|
+
run: (a) => api('DELETE', `/folders/${enc(a.folder_id)}`),
|
|
672
1051
|
},
|
|
673
1052
|
|
|
674
|
-
// ──
|
|
675
|
-
|
|
1053
|
+
// ── Campaign-bindable Tables ─────────────────────────────────────────
|
|
1054
|
+
get_tables: {
|
|
676
1055
|
def: {
|
|
677
1056
|
description:
|
|
678
|
-
'List
|
|
679
|
-
inputSchema: obj({
|
|
1057
|
+
'List campaign-bindable Scrapeloop Tables in this workspace. Returns Clay-style Tables used by imports, enrichment columns, table cells, and create_campaign(source_list_id). Distinct from legacy get_lists.',
|
|
1058
|
+
inputSchema: obj({ archived: B }),
|
|
680
1059
|
},
|
|
681
|
-
run: (a) =>
|
|
682
|
-
api(
|
|
683
|
-
'GET',
|
|
684
|
-
`/replies${qs({
|
|
685
|
-
sentiment: a.sentiment,
|
|
686
|
-
handled: a.handled,
|
|
687
|
-
lead_id: a.lead_id,
|
|
688
|
-
campaign_id: a.campaign_id,
|
|
689
|
-
status: a.status,
|
|
690
|
-
limit: a.limit,
|
|
691
|
-
offset: a.offset,
|
|
692
|
-
})}`,
|
|
693
|
-
),
|
|
1060
|
+
run: (a) => api('GET', `/tables${qs({ archived: a.archived })}`),
|
|
694
1061
|
},
|
|
695
|
-
|
|
1062
|
+
create_table: {
|
|
696
1063
|
def: {
|
|
697
1064
|
description:
|
|
698
|
-
|
|
1065
|
+
'Create a campaign-bindable Scrapeloop Table. Static is the default for imported/manual leads and accepts only name, description, and kind. filter_expression, sort_expression, visible_columns, and is_shared are smart-Table-only fields. The create is retry-safe: one operation_id is reused across HTTP retries. You may pass a prior operation_id to recover an ambiguous call; reusing it with different inputs returns 409.',
|
|
699
1066
|
inputSchema: obj(
|
|
700
1067
|
{
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1068
|
+
name: S,
|
|
1069
|
+
operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
|
|
1070
|
+
description: S,
|
|
1071
|
+
kind: { ...S, enum: ['static', 'smart'] },
|
|
1072
|
+
filter_expression: { ...O, description: 'Smart Tables only.' },
|
|
1073
|
+
sort_expression: { ...O, description: 'Smart Tables only.' },
|
|
1074
|
+
visible_columns: { ...ARR(S), description: 'Smart Tables only.' },
|
|
1075
|
+
is_shared: { ...B, description: 'Smart Tables only.' },
|
|
704
1076
|
},
|
|
705
|
-
['
|
|
1077
|
+
['name'],
|
|
706
1078
|
),
|
|
707
1079
|
},
|
|
708
|
-
run: (a) =>
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1080
|
+
run: async (a) => {
|
|
1081
|
+
if (!a.name) return { ok: false, status: 400, error: { detail: 'name is required.' } };
|
|
1082
|
+
const kind = a.kind || 'static';
|
|
1083
|
+
const smartOnly = ['filter_expression', 'sort_expression', 'visible_columns', 'is_shared'];
|
|
1084
|
+
const suppliedSmartOnly = smartOnly.filter((key) => a[key] !== undefined);
|
|
1085
|
+
if (kind === 'static' && suppliedSmartOnly.length) {
|
|
1086
|
+
return {
|
|
1087
|
+
ok: false,
|
|
1088
|
+
status: 400,
|
|
1089
|
+
error: {
|
|
1090
|
+
detail: `static Tables do not accept smart-only fields: ${suppliedSmartOnly.join(', ')}`,
|
|
1091
|
+
},
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
const requestBody = {
|
|
1095
|
+
name: a.name,
|
|
1096
|
+
operation_id: a.operation_id || randomUUID(),
|
|
1097
|
+
...(a.description !== undefined ? { description: a.description } : {}),
|
|
1098
|
+
...(a.kind !== undefined ? { kind: a.kind } : {}),
|
|
1099
|
+
...(a.filter_expression !== undefined ? { filter_expression: a.filter_expression } : {}),
|
|
1100
|
+
...(a.sort_expression !== undefined ? { sort_expression: a.sort_expression } : {}),
|
|
1101
|
+
...(a.visible_columns !== undefined ? { visible_columns: a.visible_columns } : {}),
|
|
1102
|
+
...(a.is_shared !== undefined ? { is_shared: a.is_shared } : {}),
|
|
1103
|
+
};
|
|
1104
|
+
return api('POST', '/tables', requestBody, {
|
|
1105
|
+
reconciliation: {
|
|
1106
|
+
operation_id: requestBody.operation_id,
|
|
1107
|
+
retry_with: {
|
|
1108
|
+
tool: 'create_table',
|
|
1109
|
+
arguments: { ...a, operation_id: requestBody.operation_id },
|
|
1110
|
+
},
|
|
1111
|
+
retry_guidance:
|
|
1112
|
+
`Retry create_table with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical create.`,
|
|
1113
|
+
},
|
|
1114
|
+
});
|
|
1115
|
+
},
|
|
713
1116
|
},
|
|
714
|
-
|
|
1117
|
+
get_table_columns: {
|
|
715
1118
|
def: {
|
|
716
1119
|
description:
|
|
717
|
-
'List
|
|
718
|
-
inputSchema: obj({
|
|
1120
|
+
'List a campaign-bindable Table\'s dynamic columns in display order. Use each returned id with set_table_cells and each key with list_table_rows filters/sorts.',
|
|
1121
|
+
inputSchema: obj({ table_id: S }, ['table_id']),
|
|
719
1122
|
},
|
|
720
|
-
run: (a) =>
|
|
1123
|
+
run: (a) =>
|
|
1124
|
+
a.table_id
|
|
1125
|
+
? api('GET', `/tables/${enc(a.table_id)}/columns`)
|
|
1126
|
+
: { ok: false, status: 400, error: { detail: 'table_id is required.' } },
|
|
721
1127
|
},
|
|
722
|
-
|
|
1128
|
+
create_table_column: {
|
|
723
1129
|
def: {
|
|
724
1130
|
description:
|
|
725
|
-
'
|
|
726
|
-
inputSchema: obj(
|
|
1131
|
+
'Create a FREE manual data column on an active campaign-bindable Table. Archived Tables must be unarchived first. Select and multi_select require 1-100 render-safe options shaped as {label,value,color?}; values are lowercase tokens and colors are gray, blue, teal, amber, or red. This tool cannot create enrichment/formula columns, auto-run work, or spend credits. The create is retry-safe: one operation_id is reused across HTTP retries. You may pass a prior operation_id to recover an ambiguous call; reusing it with different inputs returns 409.',
|
|
1132
|
+
inputSchema: obj(
|
|
1133
|
+
{
|
|
1134
|
+
table_id: S,
|
|
1135
|
+
label: S,
|
|
1136
|
+
operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
|
|
1137
|
+
type: {
|
|
1138
|
+
...S,
|
|
1139
|
+
enum: ['text', 'number', 'currency', 'select', 'multi_select', 'url', 'email', 'date', 'checkbox'],
|
|
1140
|
+
},
|
|
1141
|
+
width: { ...N, minimum: 60, maximum: 1200 },
|
|
1142
|
+
position_after: {
|
|
1143
|
+
anyOf: [S, { type: 'null' }],
|
|
1144
|
+
description: 'Omit to append, pass null for leftmost, or pass a column id.',
|
|
1145
|
+
},
|
|
1146
|
+
options: {
|
|
1147
|
+
type: 'array',
|
|
1148
|
+
minItems: 1,
|
|
1149
|
+
maxItems: 100,
|
|
1150
|
+
items: obj(
|
|
1151
|
+
{
|
|
1152
|
+
label: { ...S, minLength: 1, maxLength: 80 },
|
|
1153
|
+
value: {
|
|
1154
|
+
...S,
|
|
1155
|
+
minLength: 1,
|
|
1156
|
+
maxLength: 64,
|
|
1157
|
+
pattern: '^[a-z0-9][a-z0-9_-]*$',
|
|
1158
|
+
},
|
|
1159
|
+
color: { ...S, enum: ['gray', 'blue', 'teal', 'amber', 'red'] },
|
|
1160
|
+
},
|
|
1161
|
+
['label', 'value'],
|
|
1162
|
+
),
|
|
1163
|
+
},
|
|
1164
|
+
},
|
|
1165
|
+
['table_id', 'label'],
|
|
1166
|
+
),
|
|
1167
|
+
},
|
|
1168
|
+
run: async (a) => {
|
|
1169
|
+
if (!a.table_id || !a.label) {
|
|
1170
|
+
return { ok: false, status: 400, error: { detail: 'table_id and label are required.' } };
|
|
1171
|
+
}
|
|
1172
|
+
const type = a.type || 'text';
|
|
1173
|
+
const isSelect = type === 'select' || type === 'multi_select';
|
|
1174
|
+
if (isSelect && (!Array.isArray(a.options) || !a.options.length)) {
|
|
1175
|
+
return {
|
|
1176
|
+
ok: false,
|
|
1177
|
+
status: 400,
|
|
1178
|
+
error: { detail: 'select and multi_select columns require options.' },
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
if (!isSelect && a.options !== undefined) {
|
|
1182
|
+
return {
|
|
1183
|
+
ok: false,
|
|
1184
|
+
status: 400,
|
|
1185
|
+
error: { detail: 'options are allowed only for select and multi_select columns.' },
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
const requestBody = {
|
|
1189
|
+
label: a.label,
|
|
1190
|
+
operation_id: a.operation_id || randomUUID(),
|
|
1191
|
+
...(a.type !== undefined ? { type: a.type } : {}),
|
|
1192
|
+
...(a.width !== undefined ? { width: a.width } : {}),
|
|
1193
|
+
...(a.position_after !== undefined ? { position_after: a.position_after } : {}),
|
|
1194
|
+
...(a.options !== undefined ? { options: a.options } : {}),
|
|
1195
|
+
};
|
|
1196
|
+
const columnsPath = `/tables/${enc(a.table_id)}/columns`;
|
|
1197
|
+
return api('POST', columnsPath, requestBody, {
|
|
1198
|
+
reconciliation: {
|
|
1199
|
+
operation_id: requestBody.operation_id,
|
|
1200
|
+
retry_with: {
|
|
1201
|
+
tool: 'create_table_column',
|
|
1202
|
+
arguments: { ...a, operation_id: requestBody.operation_id },
|
|
1203
|
+
},
|
|
1204
|
+
retry_guidance:
|
|
1205
|
+
`Retry create_table_column with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical create.`,
|
|
1206
|
+
},
|
|
1207
|
+
});
|
|
1208
|
+
},
|
|
1209
|
+
},
|
|
1210
|
+
set_table_cells: {
|
|
1211
|
+
def: {
|
|
1212
|
+
description:
|
|
1213
|
+
'Write up to 500 FREE manual values into an active static Table in one atomic upsert (maximum 256 KiB JSON). Archived Tables must be unarchived first. Every cell must include value; explicit null clears it. Formats: text is at most 10,000 characters, number/currency is a finite number, checkbox is boolean, date is YYYY-MM-DD, email is valid and at most 320 characters, URL is absolute HTTP(S) and at most 2,048 characters, select is one configured token, and multi_select is a unique token array of at most 100 values. The whole request is validated before writing. Returns updated and skipped counts, and triggers configured row_updated rules once per changed lead. The write itself does not run enrichment or charge Scrapeloop credits.',
|
|
1214
|
+
inputSchema: obj(
|
|
1215
|
+
{
|
|
1216
|
+
table_id: S,
|
|
1217
|
+
cells: {
|
|
1218
|
+
type: 'array',
|
|
1219
|
+
minItems: 1,
|
|
1220
|
+
maxItems: MAX_TABLE_CELL_WRITES,
|
|
1221
|
+
items: obj(
|
|
1222
|
+
{
|
|
1223
|
+
lead_id: S,
|
|
1224
|
+
column_id: S,
|
|
1225
|
+
value: { description: 'Required. Pass null explicitly to clear the cell.' },
|
|
1226
|
+
},
|
|
1227
|
+
['lead_id', 'column_id', 'value'],
|
|
1228
|
+
),
|
|
1229
|
+
},
|
|
1230
|
+
},
|
|
1231
|
+
['table_id', 'cells'],
|
|
1232
|
+
),
|
|
1233
|
+
},
|
|
1234
|
+
run: (a) => {
|
|
1235
|
+
if (!a.table_id || !Array.isArray(a.cells) || !a.cells.length) {
|
|
1236
|
+
return { ok: false, status: 400, error: { detail: 'table_id and non-empty cells are required.' } };
|
|
1237
|
+
}
|
|
1238
|
+
if (a.cells.length > MAX_TABLE_CELL_WRITES) {
|
|
1239
|
+
return { ok: false, status: 400, error: { detail: 'cells accepts at most 500 items.' } };
|
|
1240
|
+
}
|
|
1241
|
+
if (a.cells.some((cell) => !Object.prototype.hasOwnProperty.call(cell, 'value'))) {
|
|
1242
|
+
return {
|
|
1243
|
+
ok: false,
|
|
1244
|
+
status: 400,
|
|
1245
|
+
error: { detail: 'every cell must include value; pass null explicitly to clear.' },
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
const requestBytes = new TextEncoder().encode(
|
|
1249
|
+
JSON.stringify({ cells: a.cells }),
|
|
1250
|
+
).length;
|
|
1251
|
+
if (requestBytes > MAX_TABLE_CELL_REQUEST_BYTES) {
|
|
1252
|
+
return {
|
|
1253
|
+
ok: false,
|
|
1254
|
+
status: 400,
|
|
1255
|
+
error: { detail: 'cells request exceeds 256 KiB; split it into smaller batches.' },
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
return api('POST', `/tables/${enc(a.table_id)}/cells`, { cells: a.cells });
|
|
1259
|
+
},
|
|
1260
|
+
},
|
|
1261
|
+
import_leads: {
|
|
1262
|
+
def: {
|
|
1263
|
+
description:
|
|
1264
|
+
'Bring your own leads: atomically import externally-sourced records into a campaign-bindable Scrapeloop Table without scraping or lead credits. A generic record needs at least one stable identity: email, linkedin_url, external_id, or an absolute source_url. Email may be null. Canonical sourcing rows may opt into sourcing_contract and must include controlled source, source_state, niche, and a valid phone or email; phone-only local businesses receive a stable source-scoped fallback identity. Source and niche tags are generated automatically. Matching is workspace-scoped and deterministic: normalized email first, then local-business phone, canonical LinkedIn, and external/source identity. The legacy dedupe_by="external_id" mode keeps external identity authoritative. A later record with a real email merges into the earlier LinkedIn/source-only lead instead of duplicating it. No placeholder emails are created. verification_status and custom_fields are preserved, and custom_fields flow to Instantly as custom variables. Target list_id or pass list_name to create-or-get a Table. Batch up to 1000 records. One operation_id is reused across HTTP retries, so an uncertain response can be recovered without duplicating leads. You may pass a prior operation_id; reusing it with different inputs returns 409. Each record: {source?, source_state?, email?, linkedin_url?, source_url?, external_id?, first_name, last_name, business_name, phone, website, address, city, state_region, country, niche, rating, reviews_count, gbp_link?, facebook?, instagram?, verification_status ("verified"|"catchall_verified"|"unverified"|"risky"|"invalid"), strategy_tags:[], custom_fields:{}}. Returns {operation_id, list_id, inserted, updated, deduped, invalid, invalid_reasons}.',
|
|
1265
|
+
inputSchema: obj(
|
|
1266
|
+
{
|
|
1267
|
+
list_id: S,
|
|
1268
|
+
list_name: S,
|
|
1269
|
+
operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
|
|
1270
|
+
records: ARR(O),
|
|
1271
|
+
dedupe_by: { ...S, description: '"email" (default, with LinkedIn/source fallbacks) or legacy "external_id" priority' },
|
|
1272
|
+
lead_type: { ...S, enum: ['local_business', 'b2b_person'] },
|
|
1273
|
+
sourcing_contract: B,
|
|
1274
|
+
default_verification_status: S,
|
|
1275
|
+
},
|
|
1276
|
+
['records'],
|
|
1277
|
+
),
|
|
1278
|
+
},
|
|
1279
|
+
run: (a) => {
|
|
1280
|
+
const records = a.records || [];
|
|
1281
|
+
const requestBody = {
|
|
1282
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
1283
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
1284
|
+
operation_id: a.operation_id || randomUUID(),
|
|
1285
|
+
records,
|
|
1286
|
+
...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
|
|
1287
|
+
...(a.lead_type ? { lead_type: a.lead_type } : {}),
|
|
1288
|
+
...(a.sourcing_contract ? { sourcing_contract: true } : {}),
|
|
1289
|
+
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
1290
|
+
};
|
|
1291
|
+
return api('POST', '/leads/import', requestBody, {
|
|
1292
|
+
reconciliation: {
|
|
1293
|
+
inspect_with: a.list_id
|
|
1294
|
+
? { tool: 'get_list_rows', arguments: { list_id: a.list_id, limit: 500, offset: 0 } }
|
|
1295
|
+
: { tool: 'get_tables', arguments: { archived: false } },
|
|
1296
|
+
expected: {
|
|
1297
|
+
record_count: records.length,
|
|
1298
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
1299
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
1300
|
+
},
|
|
1301
|
+
operation_id: requestBody.operation_id,
|
|
1302
|
+
retry_with: {
|
|
1303
|
+
tool: 'import_leads',
|
|
1304
|
+
arguments: { ...a, operation_id: requestBody.operation_id },
|
|
1305
|
+
},
|
|
1306
|
+
retry_guidance:
|
|
1307
|
+
`Retry import_leads with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical import.`,
|
|
1308
|
+
guidance:
|
|
1309
|
+
'The original tool arguments remain the reconciliation source of truth; do not infer success from row count alone.',
|
|
1310
|
+
},
|
|
1311
|
+
});
|
|
1312
|
+
},
|
|
1313
|
+
},
|
|
1314
|
+
capture_community_intent: {
|
|
1315
|
+
def: {
|
|
1316
|
+
description:
|
|
1317
|
+
'Manually record one reviewed, high-intent public community signal or admin-approved opt-in. This is not a social scraper. Private content, group member lists, membership-only evidence, reactions, follows, automated messages, cookies, and platform-control bypasses are prohibited. Required evidence is minimized to policy basis, visibility, consent mode, source and public-profile URLs, observed timestamp, evidence class, company, and reviewer note. Pending signals default to outreach_ready=false and legal_review_required=true. A business email is accepted only after approval and only when it was published for business contact or explicitly opted in. Set dry_run=true first. The mutation is idempotent through operation_id and writes reviewer lineage before any Table import.',
|
|
1318
|
+
inputSchema: obj(
|
|
1319
|
+
{
|
|
1320
|
+
policy_basis: {
|
|
1321
|
+
...S,
|
|
1322
|
+
enum: ['public_product_signal', 'admin_partnership', 'approved_form', 'webinar', 'poll', 'lead_magnet'],
|
|
1323
|
+
},
|
|
1324
|
+
visibility: { ...S, enum: ['public', 'admin_approved_opt_in', 'private', 'member_list'] },
|
|
1325
|
+
consent_mode: { ...S, enum: ['none', 'public_business_contact', 'explicit_opt_in'] },
|
|
1326
|
+
source_url: { ...S, format: 'uri' },
|
|
1327
|
+
public_profile_url: { ...S, format: 'uri' },
|
|
1328
|
+
observed_at: { ...S, format: 'date-time' },
|
|
1329
|
+
evidence_class: {
|
|
1330
|
+
...S,
|
|
1331
|
+
enum: ['product_named', 'product_problem', 'admin_approved_opt_in', 'membership_only'],
|
|
1332
|
+
},
|
|
1333
|
+
company: S,
|
|
1334
|
+
reviewer_note: {
|
|
1335
|
+
...S,
|
|
1336
|
+
description: 'Short reviewer rationale only. Do not paste the post or comment body.',
|
|
1337
|
+
},
|
|
1338
|
+
reviewer_decision: { ...S, enum: ['pending', 'approved', 'rejected'] },
|
|
1339
|
+
first_name: S,
|
|
1340
|
+
last_name: S,
|
|
1341
|
+
business_email: { ...S, format: 'email' },
|
|
1342
|
+
email_published_for_business: B,
|
|
1343
|
+
list_id: S,
|
|
1344
|
+
list_name: S,
|
|
1345
|
+
operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
|
|
1346
|
+
dry_run: B,
|
|
1347
|
+
},
|
|
1348
|
+
[
|
|
1349
|
+
'policy_basis',
|
|
1350
|
+
'visibility',
|
|
1351
|
+
'consent_mode',
|
|
1352
|
+
'source_url',
|
|
1353
|
+
'public_profile_url',
|
|
1354
|
+
'observed_at',
|
|
1355
|
+
'evidence_class',
|
|
1356
|
+
'company',
|
|
1357
|
+
'reviewer_note',
|
|
1358
|
+
],
|
|
1359
|
+
),
|
|
1360
|
+
},
|
|
1361
|
+
run: (a) => {
|
|
1362
|
+
const signal = {
|
|
1363
|
+
policy_basis: a.policy_basis,
|
|
1364
|
+
visibility: a.visibility,
|
|
1365
|
+
consent_mode: a.consent_mode,
|
|
1366
|
+
source_url: a.source_url,
|
|
1367
|
+
public_profile_url: a.public_profile_url,
|
|
1368
|
+
observed_at: a.observed_at,
|
|
1369
|
+
evidence_class: a.evidence_class,
|
|
1370
|
+
company: a.company,
|
|
1371
|
+
reviewer_note: a.reviewer_note,
|
|
1372
|
+
reviewer_decision: a.reviewer_decision || 'pending',
|
|
1373
|
+
...(a.first_name ? { first_name: a.first_name } : {}),
|
|
1374
|
+
...(a.last_name ? { last_name: a.last_name } : {}),
|
|
1375
|
+
...(a.business_email ? { business_email: a.business_email } : {}),
|
|
1376
|
+
...(a.email_published_for_business ? { email_published_for_business: true } : {}),
|
|
1377
|
+
};
|
|
1378
|
+
const requestBody = {
|
|
1379
|
+
signal,
|
|
1380
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
1381
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
1382
|
+
...(!a.dry_run ? { operation_id: a.operation_id || randomUUID() } : {}),
|
|
1383
|
+
};
|
|
1384
|
+
if (a.dry_run) {
|
|
1385
|
+
return api('POST', '/leads/community-intent/preview', requestBody);
|
|
1386
|
+
}
|
|
1387
|
+
return api('POST', '/leads/community-intent', requestBody, {
|
|
1388
|
+
reconciliation: {
|
|
1389
|
+
inspect_with: a.list_id
|
|
1390
|
+
? { tool: 'get_list_rows', arguments: { list_id: a.list_id, limit: 100, offset: 0 } }
|
|
1391
|
+
: { tool: 'get_tables', arguments: { archived: false } },
|
|
1392
|
+
expected: {
|
|
1393
|
+
company: a.company,
|
|
1394
|
+
public_profile_url: a.public_profile_url,
|
|
1395
|
+
reviewer_decision: signal.reviewer_decision,
|
|
1396
|
+
},
|
|
1397
|
+
operation_id: requestBody.operation_id,
|
|
1398
|
+
retry_with: {
|
|
1399
|
+
tool: 'capture_community_intent',
|
|
1400
|
+
arguments: { ...a, operation_id: requestBody.operation_id },
|
|
1401
|
+
},
|
|
1402
|
+
retry_guidance:
|
|
1403
|
+
`Retry capture_community_intent with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical review.`,
|
|
1404
|
+
guidance:
|
|
1405
|
+
'The reviewer decision and operation_id are the receipt source of truth. Never infer approval from a Table row alone.',
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
},
|
|
1409
|
+
},
|
|
1410
|
+
import_leads_csv: {
|
|
1411
|
+
def: {
|
|
1412
|
+
description:
|
|
1413
|
+
"Bring your own leads from CSV/TSV into a campaign-bindable Table. Rows may identify a lead by email, LinkedIn URL, external ID, or absolute source URL, so raw identities do not need placeholder emails. The parser auto-detects the delimiter, maps common headers (including linkedin_url and source_url), preserves unmapped columns as custom_fields, and normalizes verifier verdicts. A CSV containing source, source_state, and niche automatically enables the sourcing contract: every loaded row must also have a valid phone or email, source IDs are namespaced, phone-only local businesses get a stable fallback identity, and source/niche tags are generated. Matching is workspace-scoped: email first, then local-business phone, LinkedIn, and external/source identity. Set dry_run:true first to preview writes and column mapping. Mutating CSV imports are atomic and reuse one operation_id across HTTP retries. You may pass a prior operation_id; reusing it with changed CSV inputs returns 409. No scraping, lead credits, or re-verification. Target list_id or pass list_name. Up to 1000 rows per call.",
|
|
1414
|
+
inputSchema: obj(
|
|
1415
|
+
{
|
|
1416
|
+
csv: S,
|
|
1417
|
+
list_id: S,
|
|
1418
|
+
list_name: S,
|
|
1419
|
+
operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
|
|
1420
|
+
dedupe_by: { ...S, description: '"email" (default, with LinkedIn/source fallbacks) or legacy "external_id" priority' },
|
|
1421
|
+
lead_type: { ...S, enum: ['local_business', 'b2b_person'] },
|
|
1422
|
+
sourcing_contract: B,
|
|
1423
|
+
default_verification_status: S,
|
|
1424
|
+
column_map: { ...O, description: 'optional explicit header→field overrides, e.g. {"Owner":"first_name"}' },
|
|
1425
|
+
dry_run: B,
|
|
1426
|
+
},
|
|
1427
|
+
['csv'],
|
|
1428
|
+
),
|
|
1429
|
+
},
|
|
1430
|
+
run: (a) => {
|
|
1431
|
+
const requestBody = {
|
|
1432
|
+
csv: a.csv,
|
|
1433
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
1434
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
1435
|
+
...(!a.dry_run ? { operation_id: a.operation_id || randomUUID() } : {}),
|
|
1436
|
+
...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
|
|
1437
|
+
...(a.lead_type ? { lead_type: a.lead_type } : {}),
|
|
1438
|
+
...(a.sourcing_contract ? { sourcing_contract: true } : {}),
|
|
1439
|
+
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
1440
|
+
...(a.column_map ? { column_map: a.column_map } : {}),
|
|
1441
|
+
...(a.dry_run ? { dry_run: true } : {}),
|
|
1442
|
+
};
|
|
1443
|
+
if (a.dry_run) return api('POST', '/leads/import/csv', requestBody);
|
|
1444
|
+
return api('POST', '/leads/import/csv', requestBody, {
|
|
1445
|
+
reconciliation: {
|
|
1446
|
+
inspect_with: a.list_id
|
|
1447
|
+
? { tool: 'get_list_rows', arguments: { list_id: a.list_id, limit: 500, offset: 0 } }
|
|
1448
|
+
: { tool: 'get_tables', arguments: { archived: false } },
|
|
1449
|
+
expected: {
|
|
1450
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
1451
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
1452
|
+
},
|
|
1453
|
+
operation_id: requestBody.operation_id,
|
|
1454
|
+
retry_with: {
|
|
1455
|
+
tool: 'import_leads_csv',
|
|
1456
|
+
arguments: { ...a, operation_id: requestBody.operation_id },
|
|
1457
|
+
},
|
|
1458
|
+
retry_guidance:
|
|
1459
|
+
`Retry import_leads_csv with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical import.`,
|
|
1460
|
+
guidance:
|
|
1461
|
+
'Do not infer success from row count alone because the Table may already contain unrelated rows.',
|
|
1462
|
+
},
|
|
1463
|
+
});
|
|
1464
|
+
},
|
|
1465
|
+
},
|
|
1466
|
+
preview_import: {
|
|
1467
|
+
def: {
|
|
1468
|
+
description:
|
|
1469
|
+
'Dry-run for import_leads: report exactly what an import WOULD do — {would_insert, would_update, deduped, invalid, invalid_reasons, list_exists} — WITHOUT writing anything (no leads, no list, no tags created). Same inputs as import_leads. Call this first for a bring-your-own batch, show the user the plan (e.g. "adds 340 new, updates 12, skips 3 bad rows"), then confirm before import_leads.',
|
|
1470
|
+
inputSchema: obj(
|
|
1471
|
+
{
|
|
1472
|
+
list_id: S,
|
|
1473
|
+
list_name: S,
|
|
1474
|
+
records: ARR(O),
|
|
1475
|
+
dedupe_by: { ...S, description: '"email" (default, with LinkedIn/source fallbacks) or legacy "external_id" priority' },
|
|
1476
|
+
lead_type: { ...S, enum: ['local_business', 'b2b_person'] },
|
|
1477
|
+
sourcing_contract: B,
|
|
1478
|
+
default_verification_status: S,
|
|
1479
|
+
},
|
|
1480
|
+
['records'],
|
|
1481
|
+
),
|
|
1482
|
+
},
|
|
1483
|
+
run: (a) =>
|
|
1484
|
+
api('POST', '/leads/import/preview', {
|
|
1485
|
+
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
1486
|
+
...(a.list_name ? { list_name: a.list_name } : {}),
|
|
1487
|
+
records: a.records || [],
|
|
1488
|
+
...(a.dedupe_by ? { dedupe_by: a.dedupe_by } : {}),
|
|
1489
|
+
...(a.lead_type ? { lead_type: a.lead_type } : {}),
|
|
1490
|
+
...(a.sourcing_contract ? { sourcing_contract: true } : {}),
|
|
1491
|
+
...(a.default_verification_status ? { default_verification_status: a.default_verification_status } : {}),
|
|
1492
|
+
}),
|
|
1493
|
+
},
|
|
1494
|
+
get_list_rows: {
|
|
1495
|
+
def: {
|
|
1496
|
+
description:
|
|
1497
|
+
"Read a Table's imported leads with canonical identity fields (email, phone, LinkedIn, external_id, source_url, place_id), name/company/location, lead type, source platform, verification status, custom fields, tags, and position. Email remains null for raw identities until enrichment finds one. Paginated (limit up to 500, offset).",
|
|
1498
|
+
inputSchema: obj({ list_id: S, limit: N, offset: N }, ['list_id']),
|
|
1499
|
+
},
|
|
1500
|
+
run: (a) =>
|
|
1501
|
+
a.list_id
|
|
1502
|
+
? api('GET', `/lists/${enc(a.list_id)}/leads${qs({ limit: a.limit, offset: a.offset })}`)
|
|
1503
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
1504
|
+
},
|
|
1505
|
+
get_list_history: {
|
|
1506
|
+
def: {
|
|
1507
|
+
description:
|
|
1508
|
+
"A table's history: log='runs' (default, who ran which enrichment column, scope, rows, cost, and outcome) or log='changes' (column and settings edits with actor). Kept 90 days. Paginated (limit at most 200).",
|
|
1509
|
+
inputSchema: obj(
|
|
1510
|
+
{
|
|
1511
|
+
list_id: S,
|
|
1512
|
+
log: { ...S, description: '"runs" (default) or "changes"' },
|
|
1513
|
+
limit: N,
|
|
1514
|
+
before: S,
|
|
1515
|
+
},
|
|
1516
|
+
['list_id'],
|
|
1517
|
+
),
|
|
1518
|
+
},
|
|
1519
|
+
run: (a) =>
|
|
1520
|
+
a.list_id
|
|
1521
|
+
? api(
|
|
1522
|
+
'GET',
|
|
1523
|
+
`/lists/${enc(a.list_id)}/history/${a.log === 'changes' ? 'changes' : 'runs'}${qs({ limit: a.limit, before: a.before })}`,
|
|
1524
|
+
)
|
|
1525
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
1526
|
+
},
|
|
1527
|
+
get_flow_graph: {
|
|
1528
|
+
def: {
|
|
1529
|
+
description:
|
|
1530
|
+
'Get the real-object Flows graph for the workspace, one workbook, or one Table ego graph. Includes rule identity and activity, campaign feed/offload relationships, and saved node positions. scope is workspace, workbook:<uuid>, or list:<uuid>.',
|
|
1531
|
+
inputSchema: obj({ scope: { ...S, description: 'workspace, workbook:<uuid>, or list:<uuid>' } }),
|
|
1532
|
+
},
|
|
1533
|
+
run: (a) => api('GET', `/workspaces/flow-graph${qs({ scope: a.scope })}`),
|
|
1534
|
+
},
|
|
1535
|
+
save_flow_layout: {
|
|
1536
|
+
def: {
|
|
1537
|
+
description:
|
|
1538
|
+
'Save presentation-only node positions for a Flows scope. This never creates or changes rules or relationships. layout maps node ids to {x,y} coordinates and is limited to 500 nodes.',
|
|
1539
|
+
inputSchema: obj(
|
|
1540
|
+
{
|
|
1541
|
+
scope_key: { ...S, description: 'workspace, workbook:<uuid>, or list:<uuid>' },
|
|
1542
|
+
layout: { ...O, description: 'Map of node id to {x:number,y:number}' },
|
|
1543
|
+
},
|
|
1544
|
+
['scope_key', 'layout'],
|
|
1545
|
+
),
|
|
1546
|
+
},
|
|
1547
|
+
run: (a) => api('PUT', '/workspaces/flow-layout', { scope_key: a.scope_key, layout: a.layout }),
|
|
1548
|
+
},
|
|
1549
|
+
get_flows_catalog: {
|
|
1550
|
+
def: {
|
|
1551
|
+
description:
|
|
1552
|
+
'Get the live flow trigger, action, operator, config-schema, availability, and safety-limit catalog. Call this before creating a rule so unavailable epic capabilities are not selected.',
|
|
1553
|
+
inputSchema: obj({}),
|
|
1554
|
+
},
|
|
1555
|
+
run: () => api('GET', '/flows/catalog'),
|
|
1556
|
+
},
|
|
1557
|
+
list_table_flow_rules: {
|
|
1558
|
+
def: {
|
|
1559
|
+
description:
|
|
1560
|
+
"List a Table's saved automation rules, including triggers, conditions, actions, enabled state, and fire counts.",
|
|
1561
|
+
inputSchema: obj({ list_id: S }, ['list_id']),
|
|
1562
|
+
},
|
|
1563
|
+
run: (a) =>
|
|
1564
|
+
a.list_id
|
|
1565
|
+
? api('GET', `/lists/${enc(a.list_id)}/flow-rules`)
|
|
1566
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
1567
|
+
},
|
|
1568
|
+
create_table_flow_rule: {
|
|
1569
|
+
def: {
|
|
1570
|
+
description:
|
|
1571
|
+
'Create a Table automation rule. Conditions use FilterExpr. Actions route one matching row to another Table, a campaign, or a public webhook.',
|
|
1572
|
+
inputSchema: obj(
|
|
1573
|
+
{
|
|
1574
|
+
list_id: S,
|
|
1575
|
+
name: S,
|
|
1576
|
+
trigger: {
|
|
1577
|
+
...S,
|
|
1578
|
+
enum: [
|
|
1579
|
+
'row_created',
|
|
1580
|
+
'row_updated',
|
|
1581
|
+
'column_completed',
|
|
1582
|
+
'verification_completed',
|
|
1583
|
+
'schedule',
|
|
1584
|
+
],
|
|
1585
|
+
},
|
|
1586
|
+
trigger_column_id: S,
|
|
1587
|
+
condition: O,
|
|
1588
|
+
action: {
|
|
1589
|
+
...S,
|
|
1590
|
+
enum: [
|
|
1591
|
+
'send_to_table',
|
|
1592
|
+
'send_to_campaign',
|
|
1593
|
+
'send_to_webhook',
|
|
1594
|
+
'set_field',
|
|
1595
|
+
'run_columns',
|
|
1596
|
+
'suppress',
|
|
1597
|
+
'notify_email',
|
|
1598
|
+
'remove_from_campaign',
|
|
1599
|
+
'approval_gate',
|
|
1600
|
+
'send_to_crm',
|
|
1601
|
+
],
|
|
1602
|
+
},
|
|
1603
|
+
action_config: O,
|
|
1604
|
+
daily_fire_cap: N,
|
|
1605
|
+
delay_seconds: N,
|
|
1606
|
+
schedule_interval: { ...S, enum: ['hourly', 'daily', 'weekly', 'monthly'] },
|
|
1607
|
+
},
|
|
1608
|
+
['list_id', 'name', 'trigger', 'condition', 'action', 'action_config'],
|
|
1609
|
+
),
|
|
1610
|
+
},
|
|
1611
|
+
run: (a) =>
|
|
1612
|
+
a.list_id
|
|
1613
|
+
? api('POST', `/lists/${enc(a.list_id)}/flow-rules`, {
|
|
1614
|
+
name: a.name,
|
|
1615
|
+
trigger: a.trigger,
|
|
1616
|
+
...(a.trigger_column_id ? { trigger_column_id: a.trigger_column_id } : {}),
|
|
1617
|
+
condition: a.condition || {},
|
|
1618
|
+
action: a.action,
|
|
1619
|
+
action_config: a.action_config || {},
|
|
1620
|
+
...(a.daily_fire_cap != null ? { daily_fire_cap: a.daily_fire_cap } : {}),
|
|
1621
|
+
...(a.delay_seconds != null ? { delay_seconds: a.delay_seconds } : {}),
|
|
1622
|
+
...(a.schedule_interval ? { schedule_interval: a.schedule_interval } : {}),
|
|
1623
|
+
})
|
|
1624
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
1625
|
+
},
|
|
1626
|
+
toggle_table_flow_rule: {
|
|
1627
|
+
def: {
|
|
1628
|
+
description: 'Enable or disable one saved Table automation rule.',
|
|
1629
|
+
inputSchema: obj({ list_id: S, rule_id: S, enabled: B }, ['list_id', 'rule_id', 'enabled']),
|
|
1630
|
+
},
|
|
1631
|
+
run: (a) =>
|
|
1632
|
+
a.list_id && a.rule_id
|
|
1633
|
+
? api('PATCH', `/lists/${enc(a.list_id)}/flow-rules/${enc(a.rule_id)}`, {
|
|
1634
|
+
enabled: !!a.enabled,
|
|
1635
|
+
})
|
|
1636
|
+
: { ok: false, status: 400, error: { detail: 'list_id and rule_id are required.' } },
|
|
1637
|
+
},
|
|
1638
|
+
list_campaign_flow_rules: {
|
|
1639
|
+
def: {
|
|
1640
|
+
description:
|
|
1641
|
+
"List a campaign's saved automation rules. Campaign triggers remain unavailable until the catalog marks their event emitters live.",
|
|
1642
|
+
inputSchema: obj({ campaign_id: S }, ['campaign_id']),
|
|
1643
|
+
},
|
|
1644
|
+
run: (a) =>
|
|
1645
|
+
a.campaign_id
|
|
1646
|
+
? api('GET', `/campaigns/${enc(a.campaign_id)}/flow-rules`)
|
|
1647
|
+
: { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
|
|
1648
|
+
},
|
|
1649
|
+
create_campaign_flow_rule: {
|
|
1650
|
+
def: {
|
|
1651
|
+
description:
|
|
1652
|
+
'Create a campaign-scoped automation rule after get_flows_catalog reports its trigger and action available.',
|
|
1653
|
+
inputSchema: obj(
|
|
1654
|
+
{
|
|
1655
|
+
campaign_id: S,
|
|
1656
|
+
name: S,
|
|
1657
|
+
trigger: {
|
|
1658
|
+
...S,
|
|
1659
|
+
enum: [
|
|
1660
|
+
'campaign_replied',
|
|
1661
|
+
'campaign_bounced',
|
|
1662
|
+
'campaign_unsubscribed',
|
|
1663
|
+
'campaign_sequence_done',
|
|
1664
|
+
],
|
|
1665
|
+
},
|
|
1666
|
+
condition: O,
|
|
1667
|
+
action: {
|
|
1668
|
+
...S,
|
|
1669
|
+
enum: [
|
|
1670
|
+
'send_to_table',
|
|
1671
|
+
'send_to_campaign',
|
|
1672
|
+
'send_to_webhook',
|
|
1673
|
+
'set_field',
|
|
1674
|
+
'run_columns',
|
|
1675
|
+
'suppress',
|
|
1676
|
+
'notify_email',
|
|
1677
|
+
'remove_from_campaign',
|
|
1678
|
+
'approval_gate',
|
|
1679
|
+
'send_to_crm',
|
|
1680
|
+
],
|
|
1681
|
+
},
|
|
1682
|
+
action_config: O,
|
|
1683
|
+
daily_fire_cap: N,
|
|
1684
|
+
delay_seconds: N,
|
|
1685
|
+
},
|
|
1686
|
+
['campaign_id', 'name', 'trigger', 'condition', 'action', 'action_config'],
|
|
1687
|
+
),
|
|
1688
|
+
},
|
|
1689
|
+
run: (a) =>
|
|
1690
|
+
a.campaign_id
|
|
1691
|
+
? api('POST', `/campaigns/${enc(a.campaign_id)}/flow-rules`, {
|
|
1692
|
+
name: a.name,
|
|
1693
|
+
trigger: a.trigger,
|
|
1694
|
+
condition: a.condition || {},
|
|
1695
|
+
action: a.action,
|
|
1696
|
+
action_config: a.action_config || {},
|
|
1697
|
+
...(a.daily_fire_cap != null ? { daily_fire_cap: a.daily_fire_cap } : {}),
|
|
1698
|
+
...(a.delay_seconds != null ? { delay_seconds: a.delay_seconds } : {}),
|
|
1699
|
+
})
|
|
1700
|
+
: { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
|
|
1701
|
+
},
|
|
1702
|
+
toggle_campaign_flow_rule: {
|
|
1703
|
+
def: {
|
|
1704
|
+
description: 'Enable or disable one saved campaign automation rule.',
|
|
1705
|
+
inputSchema: obj(
|
|
1706
|
+
{ campaign_id: S, rule_id: S, enabled: B },
|
|
1707
|
+
['campaign_id', 'rule_id', 'enabled'],
|
|
1708
|
+
),
|
|
1709
|
+
},
|
|
1710
|
+
run: (a) =>
|
|
1711
|
+
a.campaign_id && a.rule_id
|
|
1712
|
+
? api(
|
|
1713
|
+
'PATCH',
|
|
1714
|
+
`/campaigns/${enc(a.campaign_id)}/flow-rules/${enc(a.rule_id)}`,
|
|
1715
|
+
{ enabled: !!a.enabled },
|
|
1716
|
+
)
|
|
1717
|
+
: {
|
|
1718
|
+
ok: false,
|
|
1719
|
+
status: 400,
|
|
1720
|
+
error: { detail: 'campaign_id and rule_id are required.' },
|
|
1721
|
+
},
|
|
1722
|
+
},
|
|
1723
|
+
preview_send_to_table: {
|
|
1724
|
+
def: {
|
|
1725
|
+
description:
|
|
1726
|
+
'FREE preview for sending a table view or selection into another table. ALWAYS show the new-row, already-present, and cell-copy counts before send_to_table. Finished mapped values arrive as plain data; enrichment provenance stays in the source table.',
|
|
1727
|
+
inputSchema: obj(
|
|
1728
|
+
{
|
|
1729
|
+
list_id: S,
|
|
1730
|
+
destination_list_id: S,
|
|
1731
|
+
new_table_name: S,
|
|
1732
|
+
scope: O,
|
|
1733
|
+
include_enrichment_values: B,
|
|
1734
|
+
column_mapping: ARR(O),
|
|
1735
|
+
},
|
|
1736
|
+
['list_id'],
|
|
1737
|
+
),
|
|
1738
|
+
},
|
|
1739
|
+
run: (a) =>
|
|
1740
|
+
api('POST', `/lists/${enc(a.list_id)}/send-to-list/preview`, {
|
|
1741
|
+
...(a.destination_list_id ? { destination_list_id: a.destination_list_id } : {}),
|
|
1742
|
+
...(a.new_table_name ? { new_table_name: a.new_table_name } : {}),
|
|
1743
|
+
...(a.scope ? { scope: a.scope } : {}),
|
|
1744
|
+
include_enrichment_values: !!a.include_enrichment_values,
|
|
1745
|
+
column_mapping: a.column_mapping || [],
|
|
1746
|
+
}),
|
|
1747
|
+
},
|
|
1748
|
+
send_to_table: {
|
|
1749
|
+
def: {
|
|
1750
|
+
description:
|
|
1751
|
+
'Send scoped rows into another table, optionally copying finished mapped cells as plain data. Never copies enrichment cost, status, cache, errors, or vendor provenance. Run preview_send_to_table and confirm with the user first.',
|
|
1752
|
+
inputSchema: obj(
|
|
1753
|
+
{
|
|
1754
|
+
list_id: S,
|
|
1755
|
+
destination_list_id: S,
|
|
1756
|
+
new_table_name: S,
|
|
1757
|
+
scope: O,
|
|
1758
|
+
include_enrichment_values: B,
|
|
1759
|
+
column_mapping: ARR(O),
|
|
1760
|
+
},
|
|
1761
|
+
['list_id'],
|
|
1762
|
+
),
|
|
1763
|
+
},
|
|
1764
|
+
run: (a) =>
|
|
1765
|
+
api('POST', `/lists/${enc(a.list_id)}/send-to-list`, {
|
|
1766
|
+
...(a.destination_list_id ? { destination_list_id: a.destination_list_id } : {}),
|
|
1767
|
+
...(a.new_table_name ? { new_table_name: a.new_table_name } : {}),
|
|
1768
|
+
...(a.scope ? { scope: a.scope } : {}),
|
|
1769
|
+
include_enrichment_values: !!a.include_enrichment_values,
|
|
1770
|
+
column_mapping: a.column_mapping || [],
|
|
1771
|
+
}),
|
|
1772
|
+
},
|
|
1773
|
+
preview_table_write_back: {
|
|
1774
|
+
def: {
|
|
1775
|
+
description:
|
|
1776
|
+
"FREE dry-run of writing a column's settled values onto the leads' real field (one of: email, phone, domain, name, city, state, country, postal_code, address_line1). Returns fill-empty / conflicts / duplicates / invalid counts. ALWAYS run this and show the counts before apply_table_write_back. A verify column is refused (it already writes email_status).",
|
|
1777
|
+
inputSchema: obj(
|
|
1778
|
+
{ list_id: S, column_id: S, field: S, lead_ids: ARR(S) },
|
|
1779
|
+
['list_id', 'column_id', 'field'],
|
|
1780
|
+
),
|
|
1781
|
+
},
|
|
1782
|
+
run: (a) =>
|
|
1783
|
+
api('POST', `/lists/${enc(a.list_id)}/columns/${enc(a.column_id)}/write-back/preview`, {
|
|
1784
|
+
field: a.field,
|
|
1785
|
+
...(a.lead_ids ? { lead_ids: a.lead_ids } : {}),
|
|
1786
|
+
}),
|
|
1787
|
+
},
|
|
1788
|
+
apply_table_write_back: {
|
|
1789
|
+
def: {
|
|
1790
|
+
description:
|
|
1791
|
+
"Write a column's settled cell values onto the leads' real field. Fill-empty by default; set overwrite=true to replace a DIFFERENT existing value. FREE (writes lead fields, spends nothing). A changed email resets email_status to 'unverified' unless the cell proves that exact address was verified; a known-invalid email is never written even with overwrite. Run preview_table_write_back and confirm with the user first.",
|
|
1792
|
+
inputSchema: obj(
|
|
1793
|
+
{ list_id: S, column_id: S, field: S, overwrite: B, lead_ids: ARR(S) },
|
|
1794
|
+
['list_id', 'column_id', 'field'],
|
|
1795
|
+
),
|
|
1796
|
+
},
|
|
1797
|
+
run: (a) =>
|
|
1798
|
+
api('POST', `/lists/${enc(a.list_id)}/columns/${enc(a.column_id)}/write-back`, {
|
|
1799
|
+
field: a.field,
|
|
1800
|
+
overwrite: !!a.overwrite,
|
|
1801
|
+
...(a.lead_ids ? { lead_ids: a.lead_ids } : {}),
|
|
1802
|
+
}),
|
|
1803
|
+
},
|
|
1804
|
+
export_table_csv: {
|
|
1805
|
+
def: {
|
|
1806
|
+
description:
|
|
1807
|
+
"Export a table to CSV as a background job. scope is the same view/filter/selection object used by table runs; fields are ordered lead.<field>/col.<column_key> keys and default to what the view shows. Returns job_id; poll get_table_export for a download link valid for 7 days.",
|
|
1808
|
+
inputSchema: obj(
|
|
1809
|
+
{ list_id: S, scope: O, fields: ARR(S), filename: S },
|
|
1810
|
+
['list_id'],
|
|
1811
|
+
),
|
|
1812
|
+
},
|
|
1813
|
+
run: (a) =>
|
|
1814
|
+
api('POST', `/lists/${enc(a.list_id)}/export`, {
|
|
1815
|
+
...(a.scope ? { scope: a.scope } : {}),
|
|
1816
|
+
...(a.fields ? { fields: a.fields } : {}),
|
|
1817
|
+
...(a.filename ? { filename: a.filename } : {}),
|
|
1818
|
+
}),
|
|
1819
|
+
},
|
|
1820
|
+
get_table_export: {
|
|
1821
|
+
def: {
|
|
1822
|
+
description:
|
|
1823
|
+
'Get one table export and its signed download URL, or omit job_id to list recent background exports.',
|
|
1824
|
+
inputSchema: obj({ list_id: S, job_id: S }, ['list_id']),
|
|
1825
|
+
},
|
|
1826
|
+
run: (a) =>
|
|
1827
|
+
api(
|
|
1828
|
+
'GET',
|
|
1829
|
+
a.job_id
|
|
1830
|
+
? `/lists/${enc(a.list_id)}/exports/${enc(a.job_id)}`
|
|
1831
|
+
: `/lists/${enc(a.list_id)}/exports`,
|
|
1832
|
+
),
|
|
1833
|
+
},
|
|
1834
|
+
list_table_rows: {
|
|
1835
|
+
def: {
|
|
1836
|
+
description:
|
|
1837
|
+
"Read a table's rows the way the grid sees them — lead identity fields (name/email/phone/domain/city/state/country/status) plus every column's cell (status + value). Server-side: sort is a JSON-array string like [{\"key\":\"lead.name\",\"dir\":\"asc\"},{\"key\":\"col.company_size\",\"dir\":\"desc\"}] (keys are lead.<field> or col.<column_key>, ≤3 levels); q is a full-text search across identity fields + visible column cells; filter is a FilterExpr object (same grammar the Tables filter UI uses) — e.g. {\"and\":[{\"field\":\"lead.email_status\",\"op\":\"eq\",\"value\":\"valid\"},{\"field\":\"col.company_size\",\"op\":\"gte\",\"value\":50}]} — and adds total_filtered alongside total; pass the previous response's next_cursor back as cursor to page (null next_cursor = last page). Distinct from get_list_rows (the plain import read-back). limit ≤ 500.",
|
|
1838
|
+
inputSchema: obj(
|
|
1839
|
+
{
|
|
1840
|
+
list_id: S,
|
|
1841
|
+
cursor: S,
|
|
1842
|
+
limit: N,
|
|
1843
|
+
sort: S,
|
|
1844
|
+
q: S,
|
|
1845
|
+
filter: { ...O, description: 'FilterExpr JSON (object) — server-side row filter; adds total_filtered.' },
|
|
1846
|
+
view_id: { ...S, description: 'A saved custom view uuid or a system key (errored_rows/fully_enriched/data_only) from list_table_views. An ad-hoc filter/sort replaces the view\'s.' },
|
|
1847
|
+
},
|
|
1848
|
+
['list_id'],
|
|
1849
|
+
),
|
|
1850
|
+
},
|
|
1851
|
+
run: (a) =>
|
|
1852
|
+
a.list_id
|
|
1853
|
+
? api(
|
|
1854
|
+
'GET',
|
|
1855
|
+
`/lists/${enc(a.list_id)}/rows${qs({
|
|
1856
|
+
cursor: a.cursor,
|
|
1857
|
+
limit: a.limit,
|
|
1858
|
+
sort: a.sort,
|
|
1859
|
+
q: a.q,
|
|
1860
|
+
filter: a.filter ? JSON.stringify(a.filter) : undefined,
|
|
1861
|
+
view_id: a.view_id,
|
|
1862
|
+
})}`,
|
|
1863
|
+
)
|
|
1864
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
1865
|
+
},
|
|
1866
|
+
list_table_views: {
|
|
1867
|
+
def: {
|
|
1868
|
+
description:
|
|
1869
|
+
"List a table's views — three always-current SYSTEM views (errored_rows / fully_enriched / data_only) plus the user's saved custom views. Pass a returned id (or a system key) as view_id to list_table_rows to read that view (a saved filter + sorts + column overlay).",
|
|
1870
|
+
inputSchema: obj({ list_id: S }, ['list_id']),
|
|
1871
|
+
},
|
|
1872
|
+
run: (a) =>
|
|
1873
|
+
a.list_id
|
|
1874
|
+
? api('GET', `/lists/${enc(a.list_id)}/views`)
|
|
1875
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
1876
|
+
},
|
|
1877
|
+
create_table_view: {
|
|
1878
|
+
def: {
|
|
1879
|
+
description:
|
|
1880
|
+
'Save a custom view of an active table: a named filter (FilterExpr) + sorts + column overlay + row window (all optional beyond name). filter_expression uses the same grammar as list_table_rows; sorts is [{key,dir}]. The create is retry-safe: one operation_id is reused across HTTP retries. You may pass a prior operation_id to recover an ambiguous call; reusing it with different inputs returns 409.',
|
|
1881
|
+
inputSchema: obj(
|
|
1882
|
+
{
|
|
1883
|
+
list_id: S,
|
|
1884
|
+
name: S,
|
|
1885
|
+
operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
|
|
1886
|
+
description: S,
|
|
1887
|
+
filter_expression: O,
|
|
1888
|
+
sorts: ARR(O),
|
|
1889
|
+
column_state: O,
|
|
1890
|
+
row_window: O,
|
|
1891
|
+
},
|
|
1892
|
+
['list_id', 'name'],
|
|
1893
|
+
),
|
|
1894
|
+
},
|
|
1895
|
+
run: async (a) => {
|
|
1896
|
+
if (!a.list_id || !a.name) {
|
|
1897
|
+
return { ok: false, status: 400, error: { detail: 'list_id and name are required.' } };
|
|
1898
|
+
}
|
|
1899
|
+
const viewsPath = `/lists/${enc(a.list_id)}/views`;
|
|
1900
|
+
const requestBody = {
|
|
1901
|
+
name: a.name,
|
|
1902
|
+
operation_id: a.operation_id || randomUUID(),
|
|
1903
|
+
...(a.description !== undefined ? { description: a.description } : {}),
|
|
1904
|
+
...(a.filter_expression !== undefined ? { filter_expression: a.filter_expression } : {}),
|
|
1905
|
+
...(a.sorts !== undefined ? { sorts: a.sorts } : {}),
|
|
1906
|
+
...(a.column_state !== undefined ? { column_state: a.column_state } : {}),
|
|
1907
|
+
...(a.row_window !== undefined ? { row_window: a.row_window } : {}),
|
|
1908
|
+
};
|
|
1909
|
+
return api('POST', viewsPath, requestBody, {
|
|
1910
|
+
reconciliation: {
|
|
1911
|
+
operation_id: requestBody.operation_id,
|
|
1912
|
+
retry_with: {
|
|
1913
|
+
tool: 'create_table_view',
|
|
1914
|
+
arguments: { ...a, operation_id: requestBody.operation_id },
|
|
1915
|
+
},
|
|
1916
|
+
retry_guidance:
|
|
1917
|
+
`Retry create_table_view with the same operation_id ${requestBody.operation_id}. Never substitute a new key for this logical create.`,
|
|
1918
|
+
},
|
|
1919
|
+
});
|
|
1920
|
+
},
|
|
1921
|
+
},
|
|
1922
|
+
update_table_view: {
|
|
1923
|
+
def: {
|
|
1924
|
+
description:
|
|
1925
|
+
'Update a saved table view — any field (name/description/filter_expression/sorts/column_state/row_window); a field set to null clears it. System views (errored_rows/fully_enriched/data_only) are read-only.',
|
|
1926
|
+
inputSchema: obj(
|
|
1927
|
+
{ list_id: S, view_id: S, name: S, description: S, filter_expression: O, sorts: ARR(O), column_state: O, row_window: O },
|
|
1928
|
+
['list_id', 'view_id'],
|
|
1929
|
+
),
|
|
1930
|
+
},
|
|
1931
|
+
run: (a) => {
|
|
1932
|
+
if (!a.list_id || !a.view_id) return { ok: false, status: 400, error: { detail: 'list_id and view_id are required.' } };
|
|
1933
|
+
const body = {};
|
|
1934
|
+
for (const k of ['name', 'description', 'filter_expression', 'sorts', 'column_state', 'row_window'])
|
|
1935
|
+
if (a[k] !== undefined) body[k] = a[k];
|
|
1936
|
+
return api('PATCH', `/lists/${enc(a.list_id)}/views/${enc(a.view_id)}`, body);
|
|
1937
|
+
},
|
|
1938
|
+
},
|
|
1939
|
+
delete_table_view: {
|
|
1940
|
+
def: {
|
|
1941
|
+
description:
|
|
1942
|
+
'Delete a saved table view — rows in the table are unaffected. Confirm with the user first. System views cannot be deleted.',
|
|
1943
|
+
inputSchema: obj({ list_id: S, view_id: S }, ['list_id', 'view_id']),
|
|
1944
|
+
},
|
|
1945
|
+
run: (a) =>
|
|
1946
|
+
a.list_id && a.view_id
|
|
1947
|
+
? api('DELETE', `/lists/${enc(a.list_id)}/views/${enc(a.view_id)}`)
|
|
1948
|
+
: { ok: false, status: 400, error: { detail: 'list_id and view_id are required.' } },
|
|
1949
|
+
},
|
|
1950
|
+
estimate_table_column: {
|
|
1951
|
+
def: {
|
|
1952
|
+
description:
|
|
1953
|
+
"FREE cost preview for running one enrichment column, optionally scoped to a view or selection (view_id + selection{mode:'query', filter:<FilterExpr>, exclude_ids} or {mode:'explicit', ids}). cell_filter picks which cells: 'empty_or_stale' (default in the UI — skips results still fresh under the current column config), 'failed', or 'all'; n_rows/start_row window the run (1-based). Returns lead_count + estimated_cost_usd + scope_resolved. ALWAYS show this to the user before run_table_column — it spends nothing.",
|
|
1954
|
+
inputSchema: obj(
|
|
1955
|
+
{ list_id: S, column_id: S, view_id: S, selection: O, cell_filter: S, n_rows: N, start_row: N, only_failed: B },
|
|
1956
|
+
['list_id', 'column_id'],
|
|
1957
|
+
),
|
|
1958
|
+
},
|
|
1959
|
+
run: (a) =>
|
|
1960
|
+
a.list_id && a.column_id
|
|
1961
|
+
? api('POST', `/lists/${enc(a.list_id)}/columns/${enc(a.column_id)}/estimate`, runScopeBody(a))
|
|
1962
|
+
: { ok: false, status: 400, error: { detail: 'list_id and column_id are required.' } },
|
|
1963
|
+
},
|
|
1964
|
+
run_table_column: {
|
|
1965
|
+
def: {
|
|
1966
|
+
description:
|
|
1967
|
+
"SPENDS vendor credits — enrich one column across the scoped rows (view_id + selection + cell_filter + n_rows/start_row, same shape as estimate_table_column). cell_filter='empty_or_stale' re-runs only rows that are empty or out of date — fresh results are never re-billed. Run estimate_table_column first and confirm with the user.",
|
|
1968
|
+
inputSchema: obj(
|
|
1969
|
+
{ list_id: S, column_id: S, view_id: S, selection: O, cell_filter: S, n_rows: N, start_row: N, only_failed: B },
|
|
1970
|
+
['list_id', 'column_id'],
|
|
1971
|
+
),
|
|
1972
|
+
},
|
|
1973
|
+
run: (a) =>
|
|
1974
|
+
a.list_id && a.column_id
|
|
1975
|
+
? api('POST', `/lists/${enc(a.list_id)}/run-column`, { column_id: a.column_id, ...runScopeBody(a) })
|
|
1976
|
+
: { ok: false, status: 400, error: { detail: 'list_id and column_id are required.' } },
|
|
1977
|
+
},
|
|
1978
|
+
run_scope_summary: {
|
|
1979
|
+
def: {
|
|
1980
|
+
description:
|
|
1981
|
+
'FREE counts + estimates for a column run scope — total, empty_or_stale, stale, failed rows + est_all/est_empty_or_stale/est_first_10 USD + budget. Use to decide what to run (which cell_filter) before estimate/run_table_column. Read-only.',
|
|
1982
|
+
inputSchema: obj({ list_id: S, column_id: S, view_id: S, selection: O }, ['list_id', 'column_id']),
|
|
1983
|
+
},
|
|
1984
|
+
run: (a) =>
|
|
1985
|
+
a.list_id && a.column_id
|
|
1986
|
+
? api('POST', `/lists/${enc(a.list_id)}/columns/${enc(a.column_id)}/run-scope-summary`, {
|
|
1987
|
+
...(a.view_id ? { view_id: a.view_id } : {}),
|
|
1988
|
+
...(a.selection ? { selection: a.selection } : {}),
|
|
1989
|
+
})
|
|
1990
|
+
: { ok: false, status: 400, error: { detail: 'list_id and column_id are required.' } },
|
|
1991
|
+
},
|
|
1992
|
+
estimate_table_run_all: {
|
|
1993
|
+
def: {
|
|
1994
|
+
description:
|
|
1995
|
+
'FREE per-column preview + total for running EVERY enrichment column in dependency order, optionally scoped (view_id + selection). Show before run_table_all — spends nothing.',
|
|
1996
|
+
inputSchema: obj(
|
|
1997
|
+
{ list_id: S, column_ids: ARR(S), view_id: S, selection: O, only_empty: B },
|
|
1998
|
+
['list_id'],
|
|
1999
|
+
),
|
|
2000
|
+
},
|
|
2001
|
+
run: (a) =>
|
|
2002
|
+
a.list_id
|
|
2003
|
+
? api('POST', `/lists/${enc(a.list_id)}/run-all/estimate`, {
|
|
2004
|
+
...(a.column_ids ? { column_ids: a.column_ids } : {}),
|
|
2005
|
+
...(a.view_id ? { view_id: a.view_id } : {}),
|
|
2006
|
+
...(a.selection ? { selection: a.selection } : {}),
|
|
2007
|
+
only_empty: a.only_empty !== false,
|
|
2008
|
+
})
|
|
2009
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
2010
|
+
},
|
|
2011
|
+
run_table_all: {
|
|
2012
|
+
def: {
|
|
2013
|
+
description:
|
|
2014
|
+
'SPENDS vendor credits — run every enrichment column in dependency order over the scoped rows (only empty cells run by default; cached results are free). Run estimate_table_run_all first and confirm with the user.',
|
|
2015
|
+
inputSchema: obj(
|
|
2016
|
+
{ list_id: S, column_ids: ARR(S), view_id: S, selection: O, only_empty: B },
|
|
2017
|
+
['list_id'],
|
|
2018
|
+
),
|
|
2019
|
+
},
|
|
2020
|
+
run: (a) =>
|
|
2021
|
+
a.list_id
|
|
2022
|
+
? api('POST', `/lists/${enc(a.list_id)}/run-all`, {
|
|
2023
|
+
...(a.column_ids ? { column_ids: a.column_ids } : {}),
|
|
2024
|
+
...(a.view_id ? { view_id: a.view_id } : {}),
|
|
2025
|
+
...(a.selection ? { selection: a.selection } : {}),
|
|
2026
|
+
only_empty: a.only_empty !== false,
|
|
2027
|
+
})
|
|
2028
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
2029
|
+
},
|
|
2030
|
+
get_table_cell: {
|
|
2031
|
+
def: {
|
|
2032
|
+
description:
|
|
2033
|
+
"Read one table cell's FULL enrichment payload plus provenance (status/cost/provider/ran-at/error). Use when list_table_rows returns a cell with truncated: true because the rows endpoint trims payloads over 64KB. Get lead_id from list_table_rows and column_id from get_table_columns.",
|
|
2034
|
+
inputSchema: obj({ list_id: S, lead_id: S, column_id: S }, ['list_id', 'lead_id', 'column_id']),
|
|
2035
|
+
},
|
|
2036
|
+
run: (a) =>
|
|
2037
|
+
a.list_id && a.lead_id && a.column_id
|
|
2038
|
+
? api('GET', `/lists/${enc(a.list_id)}/cells/${enc(a.lead_id)}/${enc(a.column_id)}`)
|
|
2039
|
+
: { ok: false, status: 400, error: { detail: 'list_id, lead_id and column_id are required.' } },
|
|
2040
|
+
},
|
|
2041
|
+
remove_table_rows: {
|
|
2042
|
+
def: {
|
|
2043
|
+
description:
|
|
2044
|
+
'Remove rows from a Scrapeloop table (list) by lead id — the leads stay in the workspace pool; only the table membership and that table\'s enrichment cells are removed. Get ids from list_table_rows. To delete the whole table use delete_list.',
|
|
2045
|
+
inputSchema: obj({ list_id: S, lead_ids: ARR(S) }, ['list_id', 'lead_ids']),
|
|
2046
|
+
},
|
|
2047
|
+
run: (a) =>
|
|
2048
|
+
a.list_id && Array.isArray(a.lead_ids)
|
|
2049
|
+
? api('DELETE', `/lists/${enc(a.list_id)}/rows`, { lead_ids: a.lead_ids })
|
|
2050
|
+
: { ok: false, status: 400, error: { detail: 'list_id and lead_ids are required.' } },
|
|
2051
|
+
},
|
|
2052
|
+
get_list_webhook: {
|
|
2053
|
+
def: {
|
|
2054
|
+
description:
|
|
2055
|
+
"Read a table's inbound webhook state (masked URL, rows received, 50k lifetime cap, field mapping). Returns configured:false if none exists.",
|
|
2056
|
+
inputSchema: obj({ list_id: S }, ['list_id']),
|
|
2057
|
+
},
|
|
2058
|
+
run: (a) => api('GET', `/lists/${enc(a.list_id)}/webhook`),
|
|
2059
|
+
},
|
|
2060
|
+
create_list_webhook: {
|
|
2061
|
+
def: {
|
|
2062
|
+
description:
|
|
2063
|
+
'Create or rotate a static table inbound webhook. Returns the full URL and token ONCE, so show it to the user immediately. Calling again rotates the token and the old URL stops working. Smart and system tables are not supported.',
|
|
2064
|
+
inputSchema: obj(
|
|
2065
|
+
{ list_id: S, field_map: O, default_verification_status: S },
|
|
2066
|
+
['list_id'],
|
|
2067
|
+
),
|
|
2068
|
+
},
|
|
2069
|
+
run: (a) =>
|
|
2070
|
+
api('POST', `/lists/${enc(a.list_id)}/webhook`, {
|
|
2071
|
+
...(a.field_map ? { field_map: a.field_map } : {}),
|
|
2072
|
+
...(a.default_verification_status
|
|
2073
|
+
? { default_verification_status: a.default_verification_status }
|
|
2074
|
+
: {}),
|
|
2075
|
+
}),
|
|
2076
|
+
},
|
|
2077
|
+
update_list_webhook: {
|
|
2078
|
+
def: {
|
|
2079
|
+
description: "Update a table inbound webhook's field mapping or default verification status.",
|
|
2080
|
+
inputSchema: obj(
|
|
2081
|
+
{ list_id: S, field_map: O, default_verification_status: S },
|
|
2082
|
+
['list_id'],
|
|
2083
|
+
),
|
|
2084
|
+
},
|
|
2085
|
+
run: (a) =>
|
|
2086
|
+
api('PATCH', `/lists/${enc(a.list_id)}/webhook`, {
|
|
2087
|
+
...(a.field_map !== undefined ? { field_map: a.field_map } : {}),
|
|
2088
|
+
...(a.default_verification_status !== undefined
|
|
2089
|
+
? { default_verification_status: a.default_verification_status }
|
|
2090
|
+
: {}),
|
|
2091
|
+
}),
|
|
2092
|
+
},
|
|
2093
|
+
revoke_list_webhook: {
|
|
2094
|
+
def: {
|
|
2095
|
+
description:
|
|
2096
|
+
"Revoke a table's inbound webhook. The current URL stops working immediately and this cannot be undone. Confirm with the user first.",
|
|
2097
|
+
inputSchema: obj({ list_id: S }, ['list_id']),
|
|
2098
|
+
},
|
|
2099
|
+
run: (a) => api('DELETE', `/lists/${enc(a.list_id)}/webhook`),
|
|
2100
|
+
},
|
|
2101
|
+
export_list_to_webhook: {
|
|
2102
|
+
def: {
|
|
2103
|
+
description:
|
|
2104
|
+
"Push a table's rows to a webhook URL as JSON batches of 100 (Zapier/Make/n8n/custom). Optional bearer_token is stored encrypted and reusable via credential_id. Returns a job_id, poll it with get_job. Sends lead data to an external URL: confirm the destination with the user first.",
|
|
2105
|
+
inputSchema: obj(
|
|
2106
|
+
{
|
|
2107
|
+
list_id: S,
|
|
2108
|
+
url: S,
|
|
2109
|
+
bearer_token: S,
|
|
2110
|
+
credential_id: S,
|
|
2111
|
+
lead_ids: ARR(S),
|
|
2112
|
+
},
|
|
2113
|
+
['list_id', 'url'],
|
|
2114
|
+
),
|
|
2115
|
+
},
|
|
2116
|
+
run: (a) =>
|
|
2117
|
+
a.list_id && a.url
|
|
2118
|
+
? api('POST', `/lists/${enc(a.list_id)}/export/webhook`, {
|
|
2119
|
+
url: a.url,
|
|
2120
|
+
bearer_token: a.bearer_token,
|
|
2121
|
+
credential_id: a.credential_id,
|
|
2122
|
+
lead_ids: a.lead_ids,
|
|
2123
|
+
})
|
|
2124
|
+
: {
|
|
2125
|
+
ok: false,
|
|
2126
|
+
status: 400,
|
|
2127
|
+
error: { detail: 'list_id and url are required.' },
|
|
2128
|
+
},
|
|
2129
|
+
},
|
|
2130
|
+
delete_list: {
|
|
2131
|
+
def: {
|
|
2132
|
+
description:
|
|
2133
|
+
'Delete a list (the list + its membership; the underlying leads stay in the workspace). Works for any Scrapeloop list. Irreversible — confirm with the user first.',
|
|
2134
|
+
inputSchema: obj({ list_id: S }, ['list_id']),
|
|
2135
|
+
},
|
|
2136
|
+
run: (a) =>
|
|
2137
|
+
a.list_id
|
|
2138
|
+
? api('DELETE', `/lists/${enc(a.list_id)}`)
|
|
2139
|
+
: { ok: false, status: 400, error: { detail: 'list_id is required.' } },
|
|
2140
|
+
},
|
|
2141
|
+
delete_campaign: {
|
|
2142
|
+
def: {
|
|
2143
|
+
description:
|
|
2144
|
+
'Delete a campaign (the Scrapeloop campaign + its ledger; the Instantly campaign itself is not deleted). Irreversible — if you only want to stop sending, pause_campaign instead. Confirm with the user first.',
|
|
2145
|
+
inputSchema: obj({ campaign_id: S }, ['campaign_id']),
|
|
2146
|
+
},
|
|
2147
|
+
run: (a) =>
|
|
2148
|
+
a.campaign_id
|
|
2149
|
+
? api('DELETE', `/campaigns/${enc(a.campaign_id)}`)
|
|
2150
|
+
: { ok: false, status: 400, error: { detail: 'campaign_id is required.' } },
|
|
2151
|
+
},
|
|
2152
|
+
|
|
2153
|
+
// ── Replies + suppression ─────────────────────────────────────────────
|
|
2154
|
+
list_replies: {
|
|
2155
|
+
def: {
|
|
2156
|
+
description:
|
|
2157
|
+
'List inbound campaign replies, newest first. Filters: sentiment (interested|not_interested|unsubscribed), handled (true = already actioned, false = needs attention), lead_id, campaign_id, status (pending|auto_classified|human_required|human_reviewed — human_required is the review queue).',
|
|
2158
|
+
inputSchema: obj({ sentiment: S, handled: B, lead_id: S, campaign_id: S, status: S, limit: N, offset: N }),
|
|
2159
|
+
},
|
|
2160
|
+
run: (a) =>
|
|
2161
|
+
api(
|
|
2162
|
+
'GET',
|
|
2163
|
+
`/replies${qs({
|
|
2164
|
+
sentiment: a.sentiment,
|
|
2165
|
+
handled: a.handled,
|
|
2166
|
+
lead_id: a.lead_id,
|
|
2167
|
+
campaign_id: a.campaign_id,
|
|
2168
|
+
status: a.status,
|
|
2169
|
+
limit: a.limit,
|
|
2170
|
+
offset: a.offset,
|
|
2171
|
+
})}`,
|
|
2172
|
+
),
|
|
2173
|
+
},
|
|
2174
|
+
update_reply: {
|
|
2175
|
+
def: {
|
|
2176
|
+
description:
|
|
2177
|
+
"Update a reply: mark it handled/unhandled and/or set its sentiment. Setting sentiment is a human classification — it propagates to the lead (reply_sentiment, replied_at, status transition, the unsubscribe guard for 'unsubscribed', and the workspace's auto-suppress feed).",
|
|
2178
|
+
inputSchema: obj(
|
|
2179
|
+
{
|
|
2180
|
+
reply_id: S,
|
|
2181
|
+
handled: B,
|
|
2182
|
+
sentiment: { ...S, enum: ['interested', 'not_interested', 'unsubscribed', 'neutral'] },
|
|
2183
|
+
},
|
|
2184
|
+
['reply_id'],
|
|
2185
|
+
),
|
|
2186
|
+
},
|
|
2187
|
+
run: (a) =>
|
|
2188
|
+
api('PATCH', `/replies/${enc(a.reply_id)}`, {
|
|
2189
|
+
...(a.handled !== undefined ? { handled: a.handled } : {}),
|
|
2190
|
+
...(a.sentiment ? { sentiment: a.sentiment } : {}),
|
|
2191
|
+
}),
|
|
2192
|
+
},
|
|
2193
|
+
list_suppression_entries: {
|
|
2194
|
+
def: {
|
|
2195
|
+
description:
|
|
2196
|
+
'List the workspace do-not-contact suppression entries (emails + whole domains, optionally expiring). q searches values; kind filters email|domain. Suppressed addresses are never uploaded to campaigns.',
|
|
2197
|
+
inputSchema: obj({ q: S, kind: { ...S, enum: ['email', 'domain'] }, limit: N, offset: N }),
|
|
2198
|
+
},
|
|
2199
|
+
run: (a) => api('GET', `/suppression/entries${qs({ q: a.q, kind: a.kind, limit: a.limit, offset: a.offset })}`),
|
|
2200
|
+
},
|
|
2201
|
+
add_suppression_entry: {
|
|
2202
|
+
def: {
|
|
2203
|
+
description:
|
|
2204
|
+
'Add a do-not-contact suppression entry: kind "email" (one address) or "domain" (the whole company). Optional expires_at (ISO timestamp; omit for permanent). For a bulk/CSV import pass entries: [{kind, value, reason}] (max 500 per call) instead of kind+value.',
|
|
2205
|
+
inputSchema: obj({ kind: { ...S, enum: ['email', 'domain'] }, value: S, reason: S, expires_at: S, entries: ARR(O) }),
|
|
727
2206
|
},
|
|
728
2207
|
run: (a) =>
|
|
729
2208
|
api('POST', '/suppression/entries', {
|
|
@@ -772,6 +2251,119 @@ const TOOLS = {
|
|
|
772
2251
|
: {}),
|
|
773
2252
|
}),
|
|
774
2253
|
},
|
|
2254
|
+
|
|
2255
|
+
// ── Roadmap (agency build kanban) ─────────────────────────────────────
|
|
2256
|
+
roadmap_list: {
|
|
2257
|
+
def: {
|
|
2258
|
+
description:
|
|
2259
|
+
'List every card on the agency build roadmap (planned / in_progress / deployed). Each card is a feature/bug/improvement with slug, track (claude|codex|either), priority position, deps, and has_plan. Use roadmap_get_item for the full execution plan.',
|
|
2260
|
+
inputSchema: obj({}),
|
|
2261
|
+
},
|
|
2262
|
+
run: () => api('GET', '/roadmap'),
|
|
2263
|
+
},
|
|
2264
|
+
roadmap_next_planned: {
|
|
2265
|
+
def: {
|
|
2266
|
+
description:
|
|
2267
|
+
'Fetch the next eligible planned roadmap card (highest priority whose dependencies are all deployed), INCLUDING its full execution plan (plan_md). This is what "launch the next planned thing on the roadmap" resolves to. Optionally filter by track.',
|
|
2268
|
+
inputSchema: obj({ track: { ...S, enum: ['claude', 'codex', 'either'] } }),
|
|
2269
|
+
},
|
|
2270
|
+
run: (a) => api('GET', `/roadmap/next${qs({ track: a.track })}`),
|
|
2271
|
+
},
|
|
2272
|
+
roadmap_get_item: {
|
|
2273
|
+
def: {
|
|
2274
|
+
description:
|
|
2275
|
+
'Fetch one roadmap card by slug, including its full execution plan (plan_md, markdown). Follow that plan exactly when building the card.',
|
|
2276
|
+
inputSchema: obj({ slug: S }, ['slug']),
|
|
2277
|
+
},
|
|
2278
|
+
run: (a) => api('GET', `/roadmap/${enc(a.slug)}`),
|
|
2279
|
+
},
|
|
2280
|
+
roadmap_update_status: {
|
|
2281
|
+
def: {
|
|
2282
|
+
description:
|
|
2283
|
+
'Move a roadmap card across the board: planned | in_progress | deployed. Set in_progress when you start building it; set deployed (with pr_url) once merged + live.',
|
|
2284
|
+
inputSchema: obj(
|
|
2285
|
+
{
|
|
2286
|
+
slug: S,
|
|
2287
|
+
status: { ...S, enum: ['planned', 'in_progress', 'deployed'] },
|
|
2288
|
+
pr_url: { ...S, description: 'PR link — set it when marking deployed' },
|
|
2289
|
+
},
|
|
2290
|
+
['slug', 'status'],
|
|
2291
|
+
),
|
|
2292
|
+
},
|
|
2293
|
+
run: (a) =>
|
|
2294
|
+
api('PATCH', `/roadmap/${enc(a.slug)}`, {
|
|
2295
|
+
status: a.status,
|
|
2296
|
+
...(a.pr_url ? { pr_url: a.pr_url } : {}),
|
|
2297
|
+
}),
|
|
2298
|
+
},
|
|
2299
|
+
roadmap_create_item: {
|
|
2300
|
+
def: {
|
|
2301
|
+
description:
|
|
2302
|
+
'Add a card to the build roadmap. slug is a stable lowercase-hyphen id; plan_md is the self-contained execution plan (markdown) an agent will follow; depends_on lists slugs that must be deployed first.',
|
|
2303
|
+
inputSchema: obj(
|
|
2304
|
+
{
|
|
2305
|
+
slug: S,
|
|
2306
|
+
title: S,
|
|
2307
|
+
kind: { ...S, enum: ['feature', 'bug', 'improvement'] },
|
|
2308
|
+
track: { ...S, enum: ['claude', 'codex', 'either'] },
|
|
2309
|
+
area: S,
|
|
2310
|
+
effort: { ...S, enum: ['S', 'M', 'L'] },
|
|
2311
|
+
summary: S,
|
|
2312
|
+
plan_md: S,
|
|
2313
|
+
depends_on: ARR(S),
|
|
2314
|
+
files_hint: ARR(S),
|
|
2315
|
+
position: N,
|
|
2316
|
+
},
|
|
2317
|
+
['slug', 'title'],
|
|
2318
|
+
),
|
|
2319
|
+
},
|
|
2320
|
+
run: (a) =>
|
|
2321
|
+
api('POST', '/roadmap', {
|
|
2322
|
+
slug: a.slug,
|
|
2323
|
+
title: a.title,
|
|
2324
|
+
...(a.kind ? { kind: a.kind } : {}),
|
|
2325
|
+
...(a.track ? { track: a.track } : {}),
|
|
2326
|
+
...(a.area ? { area: a.area } : {}),
|
|
2327
|
+
...(a.effort ? { effort: a.effort } : {}),
|
|
2328
|
+
...(a.summary ? { summary: a.summary } : {}),
|
|
2329
|
+
...(a.plan_md ? { plan_md: a.plan_md } : {}),
|
|
2330
|
+
...(a.depends_on ? { depends_on: a.depends_on } : {}),
|
|
2331
|
+
...(a.files_hint ? { files_hint: a.files_hint } : {}),
|
|
2332
|
+
...(a.position != null ? { position: a.position } : {}),
|
|
2333
|
+
}),
|
|
2334
|
+
},
|
|
2335
|
+
roadmap_update_plan: {
|
|
2336
|
+
def: {
|
|
2337
|
+
description:
|
|
2338
|
+
"Update a roadmap card's fields — most importantly plan_md (the execution plan). Also: title, summary, kind, track, area, effort, depends_on, files_hint, position, pr_url.",
|
|
2339
|
+
inputSchema: obj(
|
|
2340
|
+
{
|
|
2341
|
+
slug: S,
|
|
2342
|
+
plan_md: S,
|
|
2343
|
+
title: S,
|
|
2344
|
+
summary: S,
|
|
2345
|
+
kind: { ...S, enum: ['feature', 'bug', 'improvement'] },
|
|
2346
|
+
track: { ...S, enum: ['claude', 'codex', 'either'] },
|
|
2347
|
+
area: S,
|
|
2348
|
+
effort: { ...S, enum: ['S', 'M', 'L'] },
|
|
2349
|
+
depends_on: ARR(S),
|
|
2350
|
+
files_hint: ARR(S),
|
|
2351
|
+
position: N,
|
|
2352
|
+
pr_url: S,
|
|
2353
|
+
},
|
|
2354
|
+
['slug'],
|
|
2355
|
+
),
|
|
2356
|
+
},
|
|
2357
|
+
run: (a) => {
|
|
2358
|
+
const { slug, ...rest } = a;
|
|
2359
|
+
const body = Object.fromEntries(
|
|
2360
|
+
Object.entries(rest).filter(([, v]) => v !== undefined && v !== null),
|
|
2361
|
+
);
|
|
2362
|
+
return Object.keys(body).length
|
|
2363
|
+
? api('PATCH', `/roadmap/${enc(slug)}`, body)
|
|
2364
|
+
: { ok: false, status: 400, error: { detail: 'nothing to update — pass at least one field' } };
|
|
2365
|
+
},
|
|
2366
|
+
},
|
|
775
2367
|
};
|
|
776
2368
|
|
|
777
2369
|
async function serve() {
|
|
@@ -779,14 +2371,15 @@ async function serve() {
|
|
|
779
2371
|
// returns a clear, structured auth error (api() handles the missing key), and we
|
|
780
2372
|
// log exactly one warning to stderr here. The key itself is never logged.
|
|
781
2373
|
if (!API_KEY) {
|
|
782
|
-
|
|
2374
|
+
warnStartupOnce(
|
|
2375
|
+
'missing_api_key',
|
|
783
2376
|
'scrapeloop-mcp: SCRAPELOOP_API_KEY is not set — tools will return an auth error until it is. ' +
|
|
784
2377
|
'Generate a key in Scrapeloop → Settings → API access.'
|
|
785
2378
|
);
|
|
786
2379
|
}
|
|
787
2380
|
|
|
788
2381
|
const server = new Server(
|
|
789
|
-
{ name: 'scrapeloop-mcp', version: '0.
|
|
2382
|
+
{ name: 'scrapeloop-mcp', version: '0.7.0' },
|
|
790
2383
|
{ capabilities: { tools: {} } }
|
|
791
2384
|
);
|
|
792
2385
|
|
|
@@ -813,10 +2406,12 @@ async function serve() {
|
|
|
813
2406
|
// user sees the problem at startup instead of only on first use. Never blocks
|
|
814
2407
|
// serving and never logs the key.
|
|
815
2408
|
if (API_KEY) {
|
|
816
|
-
|
|
817
|
-
if (
|
|
818
|
-
|
|
819
|
-
|
|
2409
|
+
runApiKeyPreflight().then((preflight) => {
|
|
2410
|
+
if (preflight.state !== 'authenticated') {
|
|
2411
|
+
warnStartupOnce(
|
|
2412
|
+
`api_key_preflight:${preflight.state}`,
|
|
2413
|
+
formatApiKeyPreflightWarning(preflight),
|
|
2414
|
+
);
|
|
820
2415
|
}
|
|
821
2416
|
});
|
|
822
2417
|
}
|
|
@@ -827,7 +2422,14 @@ async function serve() {
|
|
|
827
2422
|
// the CLI leaves it unset and serves. (An entrypoint-URL check is unreliable when
|
|
828
2423
|
// the install path contains spaces — import.meta.url percent-encodes them but
|
|
829
2424
|
// process.argv[1] does not — so an explicit opt-out flag is used instead.)
|
|
830
|
-
export {
|
|
2425
|
+
export {
|
|
2426
|
+
TOOLS,
|
|
2427
|
+
classifyApiKeyPreflight,
|
|
2428
|
+
formatApiKeyPreflightWarning,
|
|
2429
|
+
runApiKeyPreflight,
|
|
2430
|
+
serve,
|
|
2431
|
+
warnStartupOnce,
|
|
2432
|
+
};
|
|
831
2433
|
|
|
832
2434
|
if (!process.env.SCRAPELOOP_MCP_NO_SERVE) {
|
|
833
2435
|
await serve();
|