scrapeloop-mcp 0.7.1 → 0.9.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 +4 -4
- package/package.json +23 -7
- package/src/index.js +340 -50
- package/src/mutation-policies.js +27 -0
package/README.md
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
A [Model Context Protocol](https://modelcontextprotocol.io) server for Scrapeloop.
|
|
4
4
|
**Set up and run Scrapeloop end-to-end from Claude Desktop, Cursor, ChatGPT, or any
|
|
5
|
-
MCP client**
|
|
5
|
+
MCP client** - connect integrations, scrape or pull from the lead database, verify
|
|
6
6
|
(incl. catch-all), clean, enrich, tag, and feed Instantly campaigns.
|
|
7
7
|
|
|
8
|
-
Pair it with the [`scrapeloop
|
|
8
|
+
Pair it with the [`scrapeloop` skill](../skill) for a saved Playbook setup and task
|
|
9
9
|
flow ("set up Scrapeloop").
|
|
10
10
|
|
|
11
11
|
## Setup
|
|
@@ -52,7 +52,7 @@ integrations:write scrape enrich cleaners strategies campaigns:write insta
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
A 403 with `"API key missing scope(s): …"` means the key was minted with a narrower
|
|
55
|
-
set
|
|
55
|
+
set - regenerate it with the full preset.
|
|
56
56
|
|
|
57
57
|
## Tools
|
|
58
58
|
|
|
@@ -64,7 +64,7 @@ cleaners, strategy tags, vendors, scopes, and the ordered 7 setup steps) and
|
|
|
64
64
|
|------|-------|
|
|
65
65
|
| Discovery | `get_capabilities`, `get_setup_status`, `get_credits`, `get_meta` |
|
|
66
66
|
| Integrations (BYOK) | `list_integrations`, `create_integration`, `list_credentials`, `add_credential`, `test_credential`, `list_instantly_connections` |
|
|
67
|
-
| Scrape | `list_scrapers`, `estimate_scrape`, `submit_scrape`, `list_jobs`, `get_job`, `cancel_job` |
|
|
67
|
+
| Scrape | `list_scrapers`, `estimate_scrape`, `submit_scrape`, `list_jobs`, `get_job`, `cancel_job`, `attach_job_to_table` |
|
|
68
68
|
| Lead database | `search_leads`, `estimate_leads`, `reveal_lead`, `save_leads`, `bulk_verify_leads` |
|
|
69
69
|
| Verify | `verify_email`, `verify_catchall` |
|
|
70
70
|
| Clean | `list_cleaners`, `preview_cleaners`, `apply_cleaners` |
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapeloop-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Scrapeloop MCP server
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Scrapeloop MCP server - set up and run Scrapeloop end-to-end (integrations, scraping, lead database, verification, cleaners, enrichment, strategies, Instantly campaigns) from any MCP client (Claude Desktop, Cursor, ChatGPT, …).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node --check src/index.js && node test/mcp_table_tools.mjs && node test/mcp_community_intent.mjs && node test/mcp_api_key_preflight.mjs && node test/mutation_retry_contract.mjs && node test/release_readiness.mjs",
|
|
7
|
+
"test": "node --check src/index.js && node test/mcp_table_tools.mjs && node test/mcp_playbook_plans.mjs && node test/mcp_community_intent.mjs && node test/mcp_api_key_preflight.mjs && node test/mutation_retry_contract.mjs && node test/release_readiness.mjs",
|
|
8
8
|
"release:preflight": "node scripts/release-preflight.mjs",
|
|
9
9
|
"release:verify": "node scripts/release-installed-verify.mjs"
|
|
10
10
|
},
|
|
@@ -21,10 +21,26 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
23
23
|
},
|
|
24
|
-
"keywords": [
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mcp",
|
|
26
|
+
"scrapeloop",
|
|
27
|
+
"leads",
|
|
28
|
+
"b2b",
|
|
29
|
+
"email-verification",
|
|
30
|
+
"cold-email",
|
|
31
|
+
"model-context-protocol"
|
|
32
|
+
],
|
|
25
33
|
"homepage": "https://scrapeloop.com",
|
|
26
|
-
"repository": {
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Hinkam-Lanello/scrapeloop.git",
|
|
37
|
+
"directory": "packages/mcp"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/Hinkam-Lanello/scrapeloop/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
29
45
|
"license": "UNLICENSED"
|
|
30
46
|
}
|
package/src/index.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* scrapeloop-mcp
|
|
3
|
+
* scrapeloop-mcp: Model Context Protocol server for Scrapeloop.
|
|
4
4
|
*
|
|
5
5
|
* A thin wrapper over the Scrapeloop REST API (/api/v1). Authenticates with a
|
|
6
6
|
* per-workspace API key (Settings → API access). Credit-gated actions surface
|
|
7
7
|
* 402/403 bodies verbatim so the model can self-correct or prompt a top-up.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* user already is, then walk the manifest's `steps` in order.
|
|
9
|
+
* Read get_capabilities, get_setup_status and get_playbook first. Use this
|
|
10
|
+
* workspace's saved answers for setup and later tasks, following the live
|
|
11
|
+
* manifest steps and each tool's approval and retry rules.
|
|
13
12
|
*
|
|
14
13
|
* Env:
|
|
15
|
-
* SCRAPELOOP_API_KEY required
|
|
16
|
-
* SCRAPELOOP_API_URL optional
|
|
14
|
+
* SCRAPELOOP_API_KEY required: sl_live_… key
|
|
15
|
+
* SCRAPELOOP_API_URL optional, defaults to https://api.scrapeloop.com
|
|
17
16
|
*/
|
|
18
17
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
18
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
@@ -31,6 +30,21 @@ const TIMEOUT_MS = Number(process.env.SCRAPELOOP_TIMEOUT_MS) || 30000;
|
|
|
31
30
|
const MAX_RETRIES = 3;
|
|
32
31
|
const MAX_TABLE_CELL_WRITES = 500;
|
|
33
32
|
const MAX_TABLE_CELL_REQUEST_BYTES = 256 * 1024;
|
|
33
|
+
// Native Smart Table tool keys, mirroring apps/api/lib/native_tool_columns.json.
|
|
34
|
+
// This package ships standalone, so it cannot read that file; the pairing is
|
|
35
|
+
// asserted in apps/web/lib/native-tool-columns.test.ts. Drift fails SAFE: the
|
|
36
|
+
// API rejects an unknown key and returns available_tools, and
|
|
37
|
+
// get_table_native_tools always serves the live catalog.
|
|
38
|
+
// verify_catchall is deliberately absent: its correctness needs a sibling Verify
|
|
39
|
+
// email column that has already RUN and returned Catch All or Unknown, which one
|
|
40
|
+
// create call cannot establish. Customers use verify_catchall (per address) or
|
|
41
|
+
// the in-app add-column menu. See docs/roadmap/mcp-native-tool-columns.md.
|
|
42
|
+
const NATIVE_TABLE_TOOLS = Object.freeze([
|
|
43
|
+
'verify_email',
|
|
44
|
+
'verify_phone',
|
|
45
|
+
'email_finder',
|
|
46
|
+
'business_email_finder',
|
|
47
|
+
]);
|
|
34
48
|
const TRACE_HEADER = 'X-Scrapeloop-Trace-Id';
|
|
35
49
|
const SAFE_TRACE_ID = /^[A-Za-z0-9_-]{8,80}$/;
|
|
36
50
|
const STARTUP_WARNINGS = new Set();
|
|
@@ -338,13 +352,34 @@ const runScopeBody = (a) => ({
|
|
|
338
352
|
...(a.only_failed !== undefined ? { only_failed: !!a.only_failed } : {}),
|
|
339
353
|
});
|
|
340
354
|
|
|
355
|
+
// Plan choices mirror the app wizard. Explicit scope and limits prevent a
|
|
356
|
+
// missing answer from silently becoming a country-wide paid plan.
|
|
357
|
+
const PLAN_FIELDS = {
|
|
358
|
+
name: S, kind: S, integration_id: S, country: S,
|
|
359
|
+
granularity: { ...S, enum: ['country', 'state', 'city', 'postal_code'] },
|
|
360
|
+
locations: ARR(S), location_items: ARR(O), categories: ARR(S),
|
|
361
|
+
min_population: { type: 'integer', minimum: 0 }, scrape_config: O,
|
|
362
|
+
run_mode: { ...S, enum: ['all_now', 'over_time'] }, spread_evenly: B,
|
|
363
|
+
monthly_max_leads: { type: 'integer', minimum: 1 },
|
|
364
|
+
monthly_lead_ceiling: { type: 'integer', minimum: 1 },
|
|
365
|
+
monthly_max_usd: { ...N, exclusiveMinimum: 0 },
|
|
366
|
+
monthly_max_credits: { type: 'integer', minimum: 1 },
|
|
367
|
+
spend_buffer_usd: { ...N, minimum: 0 },
|
|
368
|
+
max_leads_per_chunk: { type: 'integer', minimum: 0 },
|
|
369
|
+
chunk_interval_minutes: { type: 'integer', minimum: 0 },
|
|
370
|
+
chunk_order: { ...S, enum: ['sequential', 'largest_first', 'balanced'] },
|
|
371
|
+
rescrape_covered: B, min_age_days: { type: 'integer', minimum: 1 },
|
|
372
|
+
rescrape_after_days: { type: 'integer', minimum: 1 },
|
|
373
|
+
};
|
|
374
|
+
const PLAN_REQUIRED = ['name', 'kind', 'country', 'granularity', 'locations', 'categories', 'run_mode'];
|
|
375
|
+
|
|
341
376
|
// --- Tool registry: name → { def, run } -------------------------------------
|
|
342
377
|
const TOOLS = {
|
|
343
378
|
// ── Discovery / status ────────────────────────────────────────────────
|
|
344
379
|
get_capabilities: {
|
|
345
380
|
def: {
|
|
346
381
|
description:
|
|
347
|
-
'Fetch the live Scrapeloop capability manifest: scrapers (+ their config schemas), enrichment primitives & presets, cleaners, strategy tags, vendors, scopes, filter enums, and the ordered
|
|
382
|
+
'Fetch the live Scrapeloop capability manifest: scrapers (+ their config schemas), enrichment primitives & presets, cleaners, strategy tags, vendors, scopes, filter enums, and the ordered Playbook setup and run steps with guidance. CALL THIS FIRST and whenever unsure what is available: it reflects the workspace as it is right now.',
|
|
348
383
|
inputSchema: obj({}),
|
|
349
384
|
},
|
|
350
385
|
run: () => api('GET', '/manifest'),
|
|
@@ -352,7 +387,7 @@ const TOOLS = {
|
|
|
352
387
|
get_setup_status: {
|
|
353
388
|
def: {
|
|
354
389
|
description:
|
|
355
|
-
'Onboarding checklist booleans (integration connected, a lead revealed, a verify run, a
|
|
390
|
+
'Onboarding checklist booleans (Playbook saved, integration connected, a lead revealed, a verify run, a Table created) so you know what is already done and where to resume.',
|
|
356
391
|
inputSchema: obj({}),
|
|
357
392
|
},
|
|
358
393
|
run: () => api('GET', '/setup/status'),
|
|
@@ -402,7 +437,7 @@ const TOOLS = {
|
|
|
402
437
|
},
|
|
403
438
|
list_credentials: {
|
|
404
439
|
def: {
|
|
405
|
-
description: "List an integration's stored credentials (masked
|
|
440
|
+
description: "List an integration's stored credentials (masked: keys are never returned).",
|
|
406
441
|
inputSchema: obj({ integration_id: S }, ['integration_id']),
|
|
407
442
|
},
|
|
408
443
|
run: (a) =>
|
|
@@ -442,7 +477,7 @@ const TOOLS = {
|
|
|
442
477
|
list_scrapers: {
|
|
443
478
|
def: {
|
|
444
479
|
description:
|
|
445
|
-
'List available scrapers, whether each requires a credential, and the JSON Schema for each config.
|
|
480
|
+
'List available scrapers, whether each requires a credential, and the JSON Schema for each config. Reuse saved Playbook choices and ask only for missing fields. Read each plan capability before choosing preview_scrape_plan or a one-off estimate_scrape.',
|
|
446
481
|
inputSchema: obj({}),
|
|
447
482
|
},
|
|
448
483
|
run: () => api('GET', '/scrapers'),
|
|
@@ -470,7 +505,7 @@ const TOOLS = {
|
|
|
470
505
|
submit_scrape: {
|
|
471
506
|
def: {
|
|
472
507
|
description:
|
|
473
|
-
'Submit a scrape job. Optionally pass table_id from get_tables to add results to an existing writable static Table; omit it to keep results in All leads only. Paid scrapers spend vendor credits (BYOK) or lead credits (managed: 1 credit per NEW lead, deduped free), so confirm paid work with the user first. For every paid scraper, pass a positive hard_max_cost_usd equal to or above the estimate after the user confirms that ceiling; paid work will not start without it. A scraper with requires_credential=false is free and needs no integration_id, credential_id, or hard maximum. billing: "auto" (default) uses the workspace key when one exists, else Scrapeloop\'s managed key; "byok"/"managed" force the mode. integration_id is required for normal BYOK, optional for managed, and omitted for credential-free sources.
|
|
508
|
+
'Submit a scrape job. Optionally pass table_id from get_tables to add results to an existing writable static Table; omit it to keep results in All leads only. Paid scrapers spend vendor credits (BYOK) or lead credits (managed: 1 credit per NEW lead, deduped free), so confirm paid work with the user first. For every paid scraper, pass a positive hard_max_cost_usd equal to or above the estimate after the user confirms that ceiling; paid work will not start without it. A scraper with requires_credential=false is free and needs no integration_id, credential_id, or hard maximum. billing: "auto" (default) uses the workspace key when one exists, else Scrapeloop\'s managed key; "byok"/"managed" force the mode. integration_id is required for normal BYOK, optional for managed, and omitted for credential-free sources. Returns 402 for budget or credit limits and 409 when rescrape confirmation is needed; pass confirm_rescrape:true to proceed past a coverage conflict.',
|
|
474
509
|
inputSchema: obj(
|
|
475
510
|
{
|
|
476
511
|
kind: S,
|
|
@@ -497,18 +532,59 @@ const TOOLS = {
|
|
|
497
532
|
...(a.billing ? { billing: a.billing } : {}),
|
|
498
533
|
}),
|
|
499
534
|
},
|
|
535
|
+
get_scrape_plan_territories: {
|
|
536
|
+
def: {
|
|
537
|
+
description: 'Read places a scraper can cover before previewing a plan. Use returned values exactly. For Outscraper, pass a returned parent value to read its child cities.',
|
|
538
|
+
inputSchema: obj({ kind: S, country: S, granularity: S, min_population: N, parent: S }, ['kind', 'country']),
|
|
539
|
+
},
|
|
540
|
+
run: (a) => api('GET', `/plans/territories?${new URLSearchParams(Object.entries(a).filter(([, value]) => value !== undefined)).toString()}`),
|
|
541
|
+
},
|
|
542
|
+
preview_scrape_plan: {
|
|
543
|
+
def: {
|
|
544
|
+
description: 'Preview all-now or over-time scraping with the same limits as the app. No scrape or credential is created. Read the Playbook and scraper capabilities first. Set a lead or credit cap for credit-priced work, or a dollar cap for dollar-priced work. all_now limits apply once; over_time limits reset each month. Show costs and coverage warnings before creating.',
|
|
545
|
+
inputSchema: obj(PLAN_FIELDS, PLAN_REQUIRED),
|
|
546
|
+
},
|
|
547
|
+
run: (a) => api('POST', '/plans/preview', a),
|
|
548
|
+
},
|
|
549
|
+
create_scrape_plan: {
|
|
550
|
+
def: {
|
|
551
|
+
description: 'Save a scrape plan into an existing writable Table. Set start_mode to draft to save without starting searches or spending. The default active mode starts approved work and can spend credits or vendor money. Preview the exact choices first and stay within the approved limit and time period. Pass explicit territory values and matching billing limits. Reuse the same idempotency_key after a lost reply, inspect list_scrape_plans and never create a second plan to escape an uncertain result. Over-time limits reset monthly; a total one-time approval does not authorize recurring spend.',
|
|
552
|
+
inputSchema: obj({ ...PLAN_FIELDS, target_list_id: { ...S, format: 'uuid' }, start_mode: { ...S, enum: ['active', 'draft'] } }, [...PLAN_REQUIRED, 'target_list_id']),
|
|
553
|
+
},
|
|
554
|
+
run: ({ idempotency_key: _key, ...body }) => api('POST', '/plans', body),
|
|
555
|
+
},
|
|
556
|
+
list_scrape_plans: {
|
|
557
|
+
def: { description: 'Read this workspace scrape plans, progress and spending. Use after an uncertain plan create.', inputSchema: obj({}) },
|
|
558
|
+
run: () => api('GET', '/plans'),
|
|
559
|
+
},
|
|
560
|
+
get_scrape_plan: {
|
|
561
|
+
def: { description: 'Read one scrape plan, its saved scope, limits, progress and spending.', inputSchema: obj({ plan_id: { ...S, format: 'uuid' } }, ['plan_id']) },
|
|
562
|
+
run: (a) => api('GET', `/plans/${enc(a.plan_id)}`),
|
|
563
|
+
},
|
|
564
|
+
pause_scrape_plan: {
|
|
565
|
+
def: { description: 'Pause new work from a scrape plan. A job already running may still finish. Inspect the plan and its jobs after pausing.', inputSchema: obj({ plan_id: { ...S, format: 'uuid' } }, ['plan_id']) },
|
|
566
|
+
run: (a) => api('POST', `/plans/${enc(a.plan_id)}/pause`),
|
|
567
|
+
},
|
|
568
|
+
start_scrape_plan: {
|
|
569
|
+
def: { description: 'Start a saved draft after reviewing its scope, destination and cost limits with the user. This can spend credits or vendor money. Requires approval for the exact plan and budget. Pass expected_updated_at from the reviewed get_scrape_plan response. Changed drafts must be reviewed again. After a lost reply inspect get_scrape_plan; never retry blindly.', inputSchema: obj({ plan_id: { ...S, format: 'uuid' }, expected_updated_at: { ...S, format: 'date-time' } }, ['plan_id', 'expected_updated_at']) },
|
|
570
|
+
run: (a) => api('POST', `/plans/${enc(a.plan_id)}/start`, { expected_updated_at: a.expected_updated_at }),
|
|
571
|
+
},
|
|
500
572
|
list_jobs: {
|
|
501
573
|
def: { description: 'List recent scrape/enrich jobs in the workspace.', inputSchema: obj({}) },
|
|
502
574
|
run: () => api('GET', '/jobs'),
|
|
503
575
|
},
|
|
504
576
|
get_job: {
|
|
505
|
-
def: { description: 'Get one job with its execution steps (poll this for scrape progress).', inputSchema: obj({ job_id: S }, ['job_id']) },
|
|
577
|
+
def: { description: 'Get one job with its execution steps (poll this for scrape progress). A scrape job carries `delivery`: how many rows were found, kept, new, already yours, and added to its Table, why rows were left out, and whether adding them to the Table worked.', inputSchema: obj({ job_id: S }, ['job_id']) },
|
|
506
578
|
run: (a) => api('GET', `/jobs/${enc(a.job_id)}`),
|
|
507
579
|
},
|
|
508
580
|
cancel_job: {
|
|
509
581
|
def: { description: 'Request cancellation of a queued or running job. An active external vendor task remains nonterminal while the worker aborts it and settles final partial usage, then becomes cancelled.', inputSchema: obj({ job_id: S }, ['job_id']) },
|
|
510
582
|
run: (a) => api('POST', `/jobs/${enc(a.job_id)}/cancel`),
|
|
511
583
|
},
|
|
584
|
+
attach_job_to_table: {
|
|
585
|
+
def: { description: 'Add a finished scrape job\'s leads to its Table again. Use it only when get_job shows `delivery.attach.state` as "failed" and the job is done, and not when `delivery.attach.code` is "table_missing" (that Table was deleted). Rows already in the Table are skipped, so repeating it never adds a row twice. Returns `added`, `in_table`, and the updated `delivery`.', inputSchema: obj({ job_id: S }, ['job_id']) },
|
|
586
|
+
run: (a) => api('POST', `/jobs/${enc(a.job_id)}/attach-to-table`),
|
|
587
|
+
},
|
|
512
588
|
|
|
513
589
|
// ── Lead database ─────────────────────────────────────────────────────
|
|
514
590
|
search_leads: {
|
|
@@ -533,7 +609,7 @@ const TOOLS = {
|
|
|
533
609
|
},
|
|
534
610
|
reveal_lead: {
|
|
535
611
|
def: {
|
|
536
|
-
description: "Reveal a lead's email by global_person_id. SPENDS 1 lead credit (idempotent
|
|
612
|
+
description: "Reveal a lead's email by global_person_id. SPENDS 1 lead credit (idempotent: already-revealed leads are free).",
|
|
537
613
|
inputSchema: obj({ global_person_id: S }, ['global_person_id']),
|
|
538
614
|
},
|
|
539
615
|
run: (a) => api('POST', '/leads/reveal', { global_person_id: a.global_person_id }),
|
|
@@ -678,7 +754,7 @@ const TOOLS = {
|
|
|
678
754
|
verify_catchall: {
|
|
679
755
|
def: {
|
|
680
756
|
description:
|
|
681
|
-
'Resolve a mailbox on a catch-all domain with Scrapeloop managed verification (5 verify credits).
|
|
757
|
+
'Resolve a mailbox on a catch-all domain with Scrapeloop managed verification (5 verify credits). Optional and off unless chosen. It can resolve some unknown addresses but does not promise more replies. Estimate the eligible set and stay within the approved credit budget.',
|
|
682
758
|
inputSchema: obj({ email: S }, ['email']),
|
|
683
759
|
},
|
|
684
760
|
run: (a) => api('POST', '/verify/catchall', { email: a.email }),
|
|
@@ -737,7 +813,7 @@ const TOOLS = {
|
|
|
737
813
|
},
|
|
738
814
|
run_enrich: {
|
|
739
815
|
def: {
|
|
740
|
-
description: 'Enqueue a bulk enrichment run. SPENDS vendor credits
|
|
816
|
+
description: 'Enqueue a bulk enrichment run. SPENDS vendor credits: estimate + confirm first.',
|
|
741
817
|
inputSchema: obj({ preset_slug: S, preset_id: S, lead_ids: ARR(S), filter_query: O }),
|
|
742
818
|
},
|
|
743
819
|
run: (a) =>
|
|
@@ -768,6 +844,24 @@ const TOOLS = {
|
|
|
768
844
|
...(a.cache_settings ? { cache_settings: a.cache_settings } : {}),
|
|
769
845
|
}),
|
|
770
846
|
},
|
|
847
|
+
get_playbook: {
|
|
848
|
+
def: { description: "Read this workspace's saved Playbook and niches before setting up work.", inputSchema: obj({}) },
|
|
849
|
+
run: () => api('GET', '/playbook'),
|
|
850
|
+
},
|
|
851
|
+
update_playbook: {
|
|
852
|
+
def: {
|
|
853
|
+
description: 'Save defaults for this workspace only. Saves the sections you provide and keeps other answers. Never starts a scrape or spends credits.',
|
|
854
|
+
inputSchema: obj({ sections: { type: 'object', properties: Object.fromEntries(['channels', 'scrape', 'tables', 'checks', 'routing', 'campaigns'].map((key) => [key, { type: 'object' }])), additionalProperties: false } }, ['sections']),
|
|
855
|
+
},
|
|
856
|
+
run: (a) => api('PUT', '/playbook', a.sections),
|
|
857
|
+
},
|
|
858
|
+
set_playbook_niches: {
|
|
859
|
+
def: {
|
|
860
|
+
description: 'Save chosen niches in tiers 1, 2 or 3. A null tier removes a niche without deleting its category. Set replace to true only to replace all chosen niches.',
|
|
861
|
+
inputSchema: obj({ niches: ARR(obj({ gcid: { type: ['string', 'null'] }, name: { type: ['string', 'null'] }, tier: { type: ['integer', 'null'], enum: [1, 2, 3, null] } })), replace: B }, ['niches']),
|
|
862
|
+
},
|
|
863
|
+
run: (a) => api('PUT', '/playbook/niches', a),
|
|
864
|
+
},
|
|
771
865
|
get_ai_context: {
|
|
772
866
|
def: {
|
|
773
867
|
description: 'Get the workspace AI context (company description, ICP, buyer personas) that seeds every AI feature.',
|
|
@@ -822,7 +916,7 @@ const TOOLS = {
|
|
|
822
916
|
list_senders: {
|
|
823
917
|
def: {
|
|
824
918
|
description:
|
|
825
|
-
'List the sender vendors Scrapeloop supports (instantly, …) with each one\'s capabilities (webhooks, lead lists, async moves, bulk upload). Catalog read
|
|
919
|
+
'List the sender vendors Scrapeloop supports (instantly, …) with each one\'s capabilities (webhooks, lead lists, async moves, bulk upload). Catalog read: use it to know what a connected sender can do before configuring feed/offload.',
|
|
826
920
|
inputSchema: obj({}),
|
|
827
921
|
},
|
|
828
922
|
run: () => api('GET', '/senders'),
|
|
@@ -830,7 +924,7 @@ const TOOLS = {
|
|
|
830
924
|
list_sender_campaigns: {
|
|
831
925
|
def: {
|
|
832
926
|
description:
|
|
833
|
-
"List a sender credential's campaigns at the vendor (vendor-generic: works for any connected sender key, same shape as list_instantly_campaigns
|
|
927
|
+
"List a sender credential's campaigns at the vendor (vendor-generic: works for any connected sender key, same shape as list_instantly_campaigns; use it to pick external_list_id).",
|
|
834
928
|
inputSchema: obj({ credential_id: S }, ['credential_id']),
|
|
835
929
|
},
|
|
836
930
|
run: (a) => api('GET', `/senders/${enc(a.credential_id)}/campaigns`),
|
|
@@ -867,6 +961,10 @@ const TOOLS = {
|
|
|
867
961
|
def: { description: 'List campaigns with per-campaign stats and the bound list name.', inputSchema: obj({}) },
|
|
868
962
|
run: () => api('GET', '/campaigns'),
|
|
869
963
|
},
|
|
964
|
+
get_campaign_setup_options: {
|
|
965
|
+
def: { description: 'Read this workspace\'s channel choices and defaults for new campaign drafts. Explicit create choices win. Reading options changes nothing and starts no campaign.', inputSchema: obj({}) },
|
|
966
|
+
run: () => api('GET', '/campaigns/options'),
|
|
967
|
+
},
|
|
870
968
|
preview_add_leads_to_campaign: {
|
|
871
969
|
def: {
|
|
872
970
|
description:
|
|
@@ -894,21 +992,32 @@ const TOOLS = {
|
|
|
894
992
|
create_campaign: {
|
|
895
993
|
def: {
|
|
896
994
|
description:
|
|
897
|
-
'Create a campaign
|
|
995
|
+
'Create a draft campaign connected to a sender and a Scrapeloop Table or segment. Read get_campaign_setup_options first. Absent choices use this workspace Playbook; explicit values win. GoHighLevel SMS needs a source Table and external_list_id set to a workflow ID or contacts-only. Nothing starts until activate_campaign. Ask the user before Start.',
|
|
898
996
|
inputSchema: obj(
|
|
899
997
|
{
|
|
900
998
|
name: S,
|
|
999
|
+
sender_options: { ...O, description: "GoHighLevel SMS choices: workflow_id, tags, require_verified_mobile, voip_counts_as_mobile, missing_fields (ask, create, never)." },
|
|
1000
|
+
icon: { type: ["string", "null"], description: "One or two emoji, or null to clear." },
|
|
901
1001
|
source_list_id: S,
|
|
902
1002
|
segment_id: S,
|
|
903
1003
|
sender_credential_id: S,
|
|
904
1004
|
external_list_id: S,
|
|
905
1005
|
instantly_campaign_name: S,
|
|
906
1006
|
feed_mode: { ...S, enum: ['off', 'auto_add', 'drip'] },
|
|
1007
|
+
allow_risky_emails: B,
|
|
1008
|
+
offload_enabled: B,
|
|
1009
|
+
offload_no_reply_days: N,
|
|
1010
|
+
offload_to_instantly: B,
|
|
1011
|
+
offload_to_scrapeloop: B,
|
|
1012
|
+
offload_scrapeloop_list_id: S,
|
|
907
1013
|
drip_strategy: { ...S, enum: ['target_active', 'fixed_daily'] },
|
|
908
1014
|
drip_daily_count: N,
|
|
909
1015
|
target_active_count: N,
|
|
910
1016
|
cooldown_days: N,
|
|
911
1017
|
resting_period_days: N,
|
|
1018
|
+
recycle_mode: { ...S, enum: ['off', 'after_cooldown'] },
|
|
1019
|
+
prioritize_fresh: B,
|
|
1020
|
+
bounce_instantly_list_id: S,
|
|
912
1021
|
},
|
|
913
1022
|
['name', 'sender_credential_id', 'external_list_id'],
|
|
914
1023
|
),
|
|
@@ -917,7 +1026,10 @@ const TOOLS = {
|
|
|
917
1026
|
const body = { name: a.name, sender_credential_id: a.sender_credential_id, external_list_id: a.external_list_id };
|
|
918
1027
|
for (const k of [
|
|
919
1028
|
'source_list_id', 'segment_id', 'instantly_campaign_name', 'feed_mode', 'drip_strategy',
|
|
1029
|
+
'allow_risky_emails', 'offload_enabled', 'offload_no_reply_days', 'offload_to_instantly',
|
|
1030
|
+
'offload_to_scrapeloop', 'offload_scrapeloop_list_id',
|
|
920
1031
|
'drip_daily_count', 'target_active_count', 'cooldown_days', 'resting_period_days',
|
|
1032
|
+
'recycle_mode', 'prioritize_fresh', 'bounce_instantly_list_id', 'sender_options', 'icon',
|
|
921
1033
|
]) {
|
|
922
1034
|
if (a[k] !== undefined && a[k] !== null) body[k] = a[k];
|
|
923
1035
|
}
|
|
@@ -925,19 +1037,19 @@ const TOOLS = {
|
|
|
925
1037
|
},
|
|
926
1038
|
},
|
|
927
1039
|
update_campaign: {
|
|
928
|
-
def: { description: 'Patch a campaign (feed_mode, target_active_count, cooldown_days, external_list_id, …).', inputSchema: obj({ campaign_id: S, patch: O }, ['campaign_id', 'patch']) },
|
|
1040
|
+
def: { description: 'Patch a campaign (feed_mode, target_active_count, cooldown_days, external_list_id, …). Switching feed_mode on for an already active campaign runs the same check as campaign_readiness_check: any fail answers 422 with the report under detail.readiness.', inputSchema: obj({ campaign_id: S, patch: { ...O, description: "Campaign settings to change.", properties: { sender_options: { ...O, description: "GoHighLevel SMS settings. Only supplied keys change." }, icon: { type: ["string", "null"], description: "One or two emoji, or null to clear." } }, additionalProperties: true } }, ['campaign_id', 'patch']) },
|
|
929
1041
|
run: (a) => api('PATCH', `/campaigns/${enc(a.campaign_id)}`, a.patch || {}),
|
|
930
1042
|
},
|
|
931
1043
|
duplicate_campaign: {
|
|
932
1044
|
def: {
|
|
933
1045
|
description:
|
|
934
|
-
'Duplicate a campaign: clones ALL configuration (list/segment binding, sender credential, Instantly binding, feed/drip/offload settings, field mapping) into a new draft named "Copy of …". Created SAFE regardless of the source
|
|
1046
|
+
'Duplicate a campaign: clones ALL configuration (list/segment binding, sender credential, Instantly binding, feed/drip/offload settings, field mapping) into a new draft named "Copy of …". Created SAFE regardless of the source: feed_mode is forced to off and require_approval to true, so the copy never feeds leads to Instantly until explicitly enabled. Leads/stats/ledger are NOT copied.',
|
|
935
1047
|
inputSchema: obj({ campaign_id: S }, ['campaign_id']),
|
|
936
1048
|
},
|
|
937
1049
|
run: (a) => api('POST', `/campaigns/${enc(a.campaign_id)}/duplicate`),
|
|
938
1050
|
},
|
|
939
1051
|
activate_campaign: {
|
|
940
|
-
def: { description: 'Activate a campaign
|
|
1052
|
+
def: { description: 'Activate a campaign: STARTS the feed/send loop. Run campaign_readiness_check first; any fail makes this answer 422 with the report under detail.readiness. Confirm with the user first.', inputSchema: obj({ campaign_id: S }, ['campaign_id']) },
|
|
941
1053
|
run: (a) => api('POST', `/campaigns/${enc(a.campaign_id)}/activate`),
|
|
942
1054
|
},
|
|
943
1055
|
pause_campaign: {
|
|
@@ -952,6 +1064,53 @@ const TOOLS = {
|
|
|
952
1064
|
def: { description: "A campaign's ledger stats + best-effort live Instantly analytics.", inputSchema: obj({ campaign_id: S }, ['campaign_id']) },
|
|
953
1065
|
run: (a) => api('GET', `/campaigns/${enc(a.campaign_id)}/stats`),
|
|
954
1066
|
},
|
|
1067
|
+
get_campaign_field_mapping: {
|
|
1068
|
+
def: {
|
|
1069
|
+
description:
|
|
1070
|
+
'What a campaign sends to the sender and what each detail is called there. Returns every name the sequence uses, every source that could fill it (with how full each one is over the first 500 rows), and the obvious matches. Read this when a merge variable comes out blank in the emails.',
|
|
1071
|
+
inputSchema: obj({ campaign_id: S }, ['campaign_id']),
|
|
1072
|
+
},
|
|
1073
|
+
run: (a) => api('GET', `/campaigns/${enc(a.campaign_id)}/field-mapping`),
|
|
1074
|
+
},
|
|
1075
|
+
create_sender_fields: {
|
|
1076
|
+
def: {
|
|
1077
|
+
description: 'Create missing GoHighLevel SMS contact fields only after the user agrees. Existing fields are reused. With remember never, save the choice and create nothing. Returns requested names mapped to actual field keys; use those keys when saving the mapping.',
|
|
1078
|
+
inputSchema: obj({ campaign_id: S, names: ARR(S), remember: { type: ['string', 'null'], enum: ['create', 'never', null] } }, ['campaign_id', 'names']),
|
|
1079
|
+
},
|
|
1080
|
+
run: (a) => api('POST', `/campaigns/${enc(a.campaign_id)}/sender-fields`, { names: a.names, remember: a.remember ?? null }),
|
|
1081
|
+
},
|
|
1082
|
+
set_campaign_field_mapping: {
|
|
1083
|
+
def: {
|
|
1084
|
+
description:
|
|
1085
|
+
'Set what a campaign sends. `mapping` is {name the sender receives: where it comes from}, using lead.<column>, send.<name>, col.<table column key> or custom.<key>. `identity` may override first_name, last_name and company only, never email. `only_mapped` true means the mapping is the complete list and nothing else is sent. Saving also records that a person reviewed it, which a campaign needs before its feed can be switched on.',
|
|
1086
|
+
inputSchema: obj(
|
|
1087
|
+
{ campaign_id: S, mapping: { type: 'object' }, identity: { type: 'object' }, only_mapped: B },
|
|
1088
|
+
['campaign_id'],
|
|
1089
|
+
),
|
|
1090
|
+
},
|
|
1091
|
+
run: (a) =>
|
|
1092
|
+
api('PUT', `/campaigns/${enc(a.campaign_id)}/field-mapping`, {
|
|
1093
|
+
mapping: a.mapping || {},
|
|
1094
|
+
identity: a.identity || {},
|
|
1095
|
+
only_mapped: a.only_mapped !== false,
|
|
1096
|
+
}),
|
|
1097
|
+
},
|
|
1098
|
+
campaign_delivery_coverage: {
|
|
1099
|
+
def: {
|
|
1100
|
+
description:
|
|
1101
|
+
'What the live sequence asks for versus what the feed will actually supply. Names any merge variable that nothing fills, which goes out blank in the middle of a sentence, and any that is only filled on some leads.',
|
|
1102
|
+
inputSchema: obj({ campaign_id: S }, ['campaign_id']),
|
|
1103
|
+
},
|
|
1104
|
+
run: (a) => api('GET', `/campaigns/${enc(a.campaign_id)}/delivery-coverage`),
|
|
1105
|
+
},
|
|
1106
|
+
campaign_readiness_check: {
|
|
1107
|
+
def: {
|
|
1108
|
+
description:
|
|
1109
|
+
'Is this campaign ready to start? Run it before activate_campaign. Returns ready, sender_known, sampled and a list of checks, each with a stable key, a level (pass, warn, fail or info), one plain sentence and a fix. Any fail means activate_campaign will refuse with 422 and the same report under detail.readiness. A warn does not block: read it out to the user before they confirm. It judges the leads the feed sends next (not the ones already in the campaign). With feed_mode off nothing is uploaded, so the lead and mailbox checks only warn until the feed is switched on.',
|
|
1110
|
+
inputSchema: obj({ campaign_id: S }, ['campaign_id']),
|
|
1111
|
+
},
|
|
1112
|
+
run: (a) => api('GET', `/campaigns/${enc(a.campaign_id)}/readiness`),
|
|
1113
|
+
},
|
|
955
1114
|
get_sender_config: {
|
|
956
1115
|
def: { description: "Get an Instantly credential's sender config (daily cap, send accounts, …).", inputSchema: obj({ credential_id: S }, ['credential_id']) },
|
|
957
1116
|
run: (a) => api('GET', `/sender_configs/${enc(a.credential_id)}`),
|
|
@@ -1004,6 +1163,35 @@ const TOOLS = {
|
|
|
1004
1163
|
},
|
|
1005
1164
|
run: () => api('GET', '/workbench/tree'),
|
|
1006
1165
|
},
|
|
1166
|
+
get_pipeline: {
|
|
1167
|
+
def: {
|
|
1168
|
+
description: 'Read the saved result and progress for a pipeline. Use its operation_id after an interrupted create.',
|
|
1169
|
+
inputSchema: obj({ operation_id: { ...S, format: 'uuid' } }, ['operation_id']),
|
|
1170
|
+
},
|
|
1171
|
+
run: (a) => api('GET', `/pipelines/${enc(a.operation_id)}`),
|
|
1172
|
+
},
|
|
1173
|
+
preview_pipeline: {
|
|
1174
|
+
def: {
|
|
1175
|
+
description: 'Preview an empty pipeline from this workspace Playbook, including Tables, columns, checks, rules and future-row work. Creates nothing. Review this before create_pipeline.',
|
|
1176
|
+
inputSchema: obj({}),
|
|
1177
|
+
},
|
|
1178
|
+
run: (a) => api('POST', '/pipelines/preview', a),
|
|
1179
|
+
},
|
|
1180
|
+
create_pipeline: {
|
|
1181
|
+
def: {
|
|
1182
|
+
description: 'Create an empty pipeline from this workspace Playbook. Raw keeps originals. Rules start on, and chosen columns run on future rows. Creation starts no scrape, sends no messages and activates no campaign. Read get_playbook first. Reuse the same operation_id and choices to resume a partial create. Campaign bindings require campaigns:write and feeds off.',
|
|
1183
|
+
inputSchema: obj({
|
|
1184
|
+
operation_id: { ...S, format: 'uuid' }, name: S, market: S,
|
|
1185
|
+
niche_gcids: ARR(S), mode: { ...S, enum: ['auto', 'simple', 'advanced'] },
|
|
1186
|
+
channels: ARR({ ...S, enum: ['email', 'sms', 'linkedin'] }),
|
|
1187
|
+
checks: { ...O, description: 'Overrides for saved checks, using the Playbook checks keys.' },
|
|
1188
|
+
stage_mode: { ...S, enum: ['move', 'copy'] }, carry_values: B,
|
|
1189
|
+
raw_setup: { ...O, description: 'Optional Raw columns, packs and layout. Same shape as create_table table_setup.' },
|
|
1190
|
+
bindings: { ...O, description: 'Optional email, sms or linkedin campaign IDs. Existing campaign feeds must be off.' },
|
|
1191
|
+
}, ['operation_id', 'name']),
|
|
1192
|
+
},
|
|
1193
|
+
run: (a) => api('POST', '/pipelines', a),
|
|
1194
|
+
},
|
|
1007
1195
|
create_workbook: {
|
|
1008
1196
|
def: {
|
|
1009
1197
|
description:
|
|
@@ -1016,7 +1204,7 @@ const TOOLS = {
|
|
|
1016
1204
|
def: {
|
|
1017
1205
|
description:
|
|
1018
1206
|
'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.',
|
|
1019
|
-
inputSchema: obj({ workbook_id: S, list_id: S, new_table_name: S }, ['workbook_id']),
|
|
1207
|
+
inputSchema: obj({ workbook_id: S, list_id: S, new_table_name: S, operation_id: { ...S, format: 'uuid' }, table_setup: { ...O, description: 'New Tables only. Same saved-column, pack and layout choices as create_table.' } }, ['workbook_id']),
|
|
1020
1208
|
},
|
|
1021
1209
|
run: (a) => {
|
|
1022
1210
|
if (!!a.list_id === !!a.new_table_name) {
|
|
@@ -1029,6 +1217,8 @@ const TOOLS = {
|
|
|
1029
1217
|
return api('POST', `/workbooks/${enc(a.workbook_id)}/tables`, {
|
|
1030
1218
|
...(a.list_id ? { list_id: a.list_id } : {}),
|
|
1031
1219
|
...(a.new_table_name ? { new_table_name: a.new_table_name } : {}),
|
|
1220
|
+
...(a.operation_id ? { operation_id: a.operation_id } : {}),
|
|
1221
|
+
...(a.table_setup !== undefined ? { table_setup: a.table_setup } : {}),
|
|
1032
1222
|
});
|
|
1033
1223
|
},
|
|
1034
1224
|
},
|
|
@@ -1120,16 +1310,33 @@ const TOOLS = {
|
|
|
1120
1310
|
},
|
|
1121
1311
|
run: (a) => api('GET', `/tables${qs({ archived: a.archived })}`),
|
|
1122
1312
|
},
|
|
1313
|
+
get_table_setup_options: {
|
|
1314
|
+
def: {
|
|
1315
|
+
description: 'Read this workspace\'s saved Table columns, layout and available column packs. Read-only; starts no work.',
|
|
1316
|
+
inputSchema: obj({}),
|
|
1317
|
+
},
|
|
1318
|
+
run: () => api('GET', '/playbook/tables/options'),
|
|
1319
|
+
},
|
|
1123
1320
|
create_table: {
|
|
1124
1321
|
def: {
|
|
1125
1322
|
description:
|
|
1126
|
-
'Create a
|
|
1323
|
+
'Create a Scrapeloop Table. Regular Tables start with this workspace\'s saved columns and layout. Use table_setup to change columns, choose packs or rename labels. Creation never runs columns or spends credits. Smart Tables use filter_expression, sort_expression, visible_columns and is_shared, and cannot use table_setup. Retry with the same operation_id; different inputs return 409.',
|
|
1127
1324
|
inputSchema: obj(
|
|
1128
1325
|
{
|
|
1129
1326
|
name: S,
|
|
1130
1327
|
operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
|
|
1131
1328
|
description: S,
|
|
1132
1329
|
kind: { ...S, enum: ['static', 'smart'] },
|
|
1330
|
+
table_setup: {
|
|
1331
|
+
...O,
|
|
1332
|
+
description: 'Regular Tables only. Read get_table_setup_options first. columns replaces saved columns (empty list means none); packs adds named packs; identity_column_state replaces the saved layout. All created columns start with auto-run off.',
|
|
1333
|
+
properties: {
|
|
1334
|
+
columns: ARR({ ...O, properties: { kind: { ...S, enum: ['native', 'preset'] }, key: S, label: S, config: O }, required: ['kind', 'key', 'label'] }),
|
|
1335
|
+
packs: ARR(S),
|
|
1336
|
+
identity_column_state: O,
|
|
1337
|
+
},
|
|
1338
|
+
additionalProperties: false,
|
|
1339
|
+
},
|
|
1133
1340
|
filter_expression: { ...O, description: 'Smart Tables only.' },
|
|
1134
1341
|
sort_expression: { ...O, description: 'Smart Tables only.' },
|
|
1135
1342
|
visible_columns: { ...ARR(S), description: 'Smart Tables only.' },
|
|
@@ -1157,6 +1364,7 @@ const TOOLS = {
|
|
|
1157
1364
|
operation_id: a.operation_id || randomUUID(),
|
|
1158
1365
|
...(a.description !== undefined ? { description: a.description } : {}),
|
|
1159
1366
|
...(a.kind !== undefined ? { kind: a.kind } : {}),
|
|
1367
|
+
...(a.table_setup !== undefined ? { table_setup: a.table_setup } : {}),
|
|
1160
1368
|
...(a.filter_expression !== undefined ? { filter_expression: a.filter_expression } : {}),
|
|
1161
1369
|
...(a.sort_expression !== undefined ? { sort_expression: a.sort_expression } : {}),
|
|
1162
1370
|
...(a.visible_columns !== undefined ? { visible_columns: a.visible_columns } : {}),
|
|
@@ -1338,6 +1546,86 @@ const TOOLS = {
|
|
|
1338
1546
|
});
|
|
1339
1547
|
},
|
|
1340
1548
|
},
|
|
1549
|
+
get_table_native_tools: {
|
|
1550
|
+
def: {
|
|
1551
|
+
description:
|
|
1552
|
+
'List the first-party Scrapeloop tools a Smart Table column can be built on, with their per-row credit price, documented options, and the legacy preset slugs each one supersedes. Read this when create_table_preset_column refuses a slug as superseded, or before create_table_native_column, so the tool key and options are exact rather than guessed.',
|
|
1553
|
+
inputSchema: obj({}, []),
|
|
1554
|
+
},
|
|
1555
|
+
run: () => api('GET', '/tables/native-tools'),
|
|
1556
|
+
},
|
|
1557
|
+
create_table_native_column: {
|
|
1558
|
+
def: {
|
|
1559
|
+
description:
|
|
1560
|
+
`Attach one native Scrapeloop tool as a real Smart Table column: ${NATIVE_TABLE_TOOLS.join(', ')}. These are the first-party tools that superseded four legacy presets, so they are addressed by tool key here rather than by preset slug through create_table_preset_column (which refuses a superseded slug for a new column). Each bills MANAGED VERIFICATION CREDITS per row, never dollars and never the customer's own vendor keys: verify_email reserves 1 credit per selected row, verify_phone reserves 5, and each email finder reserves 3. Settlement charges only fresh vendor checks and returns the unused reservation; missing inputs, cached rows, and skipped rows are free. Creating a column is always free. auto_run defaults false and queues nothing; when auto_run would backfill existing rows the API returns a 409 estimate whose cost.total_credits is the real charge, and you confirm it with confirm_auto_run plus confirmed_credits (CREDITS, not dollars - estimated_cost_usd is our vendor cost and is far smaller than what the workspace is billed). An auto_run that exceeds the workspace verify-credit balance returns 402 with {credit_kind, required, available} before anything is created. The Table master auto-run switch and workspace budget caps stay fail-closed. options are per tool and only the documented names are accepted - call get_table_native_tools for the current list. Owner email finder needs an owner first name plus the company website; point options.owner_name_column at an Owner name finder column to source the name, which auto_run requires. Business email finder is a SEPARATE column - create it with its own call and set options.owner_email_column to run it behind an owner finder. The Catch-all verifier is NOT creatable here: it needs a Verify email column that has already RUN and returned Catch All or Unknown, so use verify_catchall per address, or add the column from the Table's add-column menu.`,
|
|
1561
|
+
inputSchema: obj(
|
|
1562
|
+
{
|
|
1563
|
+
table_id: S,
|
|
1564
|
+
tool: { ...S, enum: NATIVE_TABLE_TOOLS },
|
|
1565
|
+
label: { ...S, description: 'Column label. Defaults to the tool name.' },
|
|
1566
|
+
auto_run: B,
|
|
1567
|
+
confirm_auto_run: B,
|
|
1568
|
+
confirmed_credits: {
|
|
1569
|
+
...N,
|
|
1570
|
+
minimum: 0,
|
|
1571
|
+
description:
|
|
1572
|
+
'The 409 estimate\'s cost.total_credits, echoed back exactly. Verification credits, not dollars.',
|
|
1573
|
+
},
|
|
1574
|
+
options: {
|
|
1575
|
+
...O,
|
|
1576
|
+
description:
|
|
1577
|
+
'Tool-specific options. verify_phone: phone_col, default_region. email_finder: owner_name_column. business_email_finder: owner_email_column. An undocumented name is rejected, never ignored.',
|
|
1578
|
+
},
|
|
1579
|
+
operation_id: {
|
|
1580
|
+
...S,
|
|
1581
|
+
format: 'uuid',
|
|
1582
|
+
description: 'Required stable retry key for this logical native-column create.',
|
|
1583
|
+
},
|
|
1584
|
+
},
|
|
1585
|
+
['table_id', 'tool', 'operation_id'],
|
|
1586
|
+
),
|
|
1587
|
+
},
|
|
1588
|
+
run: async (a) => {
|
|
1589
|
+
if (!a.table_id || !a.tool || !a.operation_id) {
|
|
1590
|
+
return {
|
|
1591
|
+
ok: false,
|
|
1592
|
+
status: 400,
|
|
1593
|
+
error: { detail: 'table_id, tool, and a stable operation_id are required.' },
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
if (!NATIVE_TABLE_TOOLS.includes(a.tool)) {
|
|
1597
|
+
return {
|
|
1598
|
+
ok: false,
|
|
1599
|
+
status: 400,
|
|
1600
|
+
error: {
|
|
1601
|
+
detail: `Unknown native tool '${a.tool}'. Expected one of ${NATIVE_TABLE_TOOLS.join(', ')}. Call get_table_native_tools for the live catalog.`,
|
|
1602
|
+
},
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
const requestBody = {
|
|
1606
|
+
tool: a.tool,
|
|
1607
|
+
operation_id: a.operation_id,
|
|
1608
|
+
...(a.label !== undefined ? { label: a.label } : {}),
|
|
1609
|
+
...(a.auto_run !== undefined ? { auto_run: a.auto_run } : {}),
|
|
1610
|
+
...(a.confirm_auto_run !== undefined ? { confirm_auto_run: a.confirm_auto_run } : {}),
|
|
1611
|
+
...(a.confirmed_credits !== undefined
|
|
1612
|
+
? { confirmed_credits: a.confirmed_credits }
|
|
1613
|
+
: {}),
|
|
1614
|
+
...(a.options !== undefined ? { options: a.options } : {}),
|
|
1615
|
+
};
|
|
1616
|
+
return api('POST', `/tables/${enc(a.table_id)}/native-columns`, requestBody, {
|
|
1617
|
+
reconciliation: {
|
|
1618
|
+
operation_id: a.operation_id,
|
|
1619
|
+
retry_with: {
|
|
1620
|
+
tool: 'create_table_native_column',
|
|
1621
|
+
arguments: { ...a, operation_id: a.operation_id },
|
|
1622
|
+
},
|
|
1623
|
+
retry_guidance:
|
|
1624
|
+
`Retry create_table_native_column with the same operation_id ${a.operation_id}. Never substitute a new key for this logical create.`,
|
|
1625
|
+
},
|
|
1626
|
+
});
|
|
1627
|
+
},
|
|
1628
|
+
},
|
|
1341
1629
|
set_table_cells: {
|
|
1342
1630
|
def: {
|
|
1343
1631
|
description:
|
|
@@ -1453,7 +1741,7 @@ const TOOLS = {
|
|
|
1453
1741
|
preview_apify_import: {
|
|
1454
1742
|
def: {
|
|
1455
1743
|
description:
|
|
1456
|
-
"Read-only preview of an approved Apify actor's already-produced dataset before importing it. mapping is {lead_field: dotted.source.path} where lead_field is one of name, email, phone, domain, organization_name, city, state, country, linkedin_url, title. Normalizes the whole dataset (rows with no email/phone/domain/linkedin/name are skipped), returns the first 25 rows plus record_count (the true total) and a preview_hash. Pass that preview_hash to import_apify_dataset
|
|
1744
|
+
"Read-only preview of an approved Apify actor's already-produced dataset before importing it. mapping is {lead_field: dotted.source.path} where lead_field is one of name, email, phone, domain, organization_name, city, state, country, linkedin_url, title. Normalizes the whole dataset (rows with no email/phone/domain/linkedin/name are skipped), returns the first 25 rows plus record_count (the true total) and a preview_hash. Pass that preview_hash to import_apify_dataset: the import re-verifies it and refuses (409) if the dataset changed. Does NOT run the actor or spend: it reads an existing dataset_id. Returns {dataset_id, actor_id, record_count, preview, preview_truncated, preview_hash}.",
|
|
1457
1745
|
inputSchema: obj(
|
|
1458
1746
|
{
|
|
1459
1747
|
dataset_id: { ...S, description: 'Apify dataset id from a completed actor run.' },
|
|
@@ -1468,7 +1756,7 @@ const TOOLS = {
|
|
|
1468
1756
|
import_apify_dataset: {
|
|
1469
1757
|
def: {
|
|
1470
1758
|
description:
|
|
1471
|
-
'Import a previewed Apify dataset (≤1000 rows) into a private Scrapeloop Table through the bring-your-own-leads funnel (dedupe + identity matching + Table attach reused; no lead credits). Call preview_apify_import first and pass its preview_hash
|
|
1759
|
+
'Import a previewed Apify dataset (≤1000 rows) into a private Scrapeloop Table through the bring-your-own-leads funnel (dedupe + identity matching + Table attach reused; no lead credits). Call preview_apify_import first and pass its preview_hash: the import re-reads the dataset and returns 409 (preview_drift) if it changed since preview. Each row gets a stable external_id (apify:{dataset}:{row}) so re-importing updates rather than duplicates; company lands in business_name and the contact name/title ride through as custom fields. Target list_id, or omit for a new "Apify import" Table. Returns the import funnel result {inserted, updated, deduped, invalid, ...}.',
|
|
1472
1760
|
inputSchema: obj(
|
|
1473
1761
|
{
|
|
1474
1762
|
dataset_id: S,
|
|
@@ -1646,7 +1934,7 @@ const TOOLS = {
|
|
|
1646
1934
|
preview_import: {
|
|
1647
1935
|
def: {
|
|
1648
1936
|
description:
|
|
1649
|
-
'Dry-run for import_leads: report exactly what an import WOULD do
|
|
1937
|
+
'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.',
|
|
1650
1938
|
inputSchema: obj(
|
|
1651
1939
|
{
|
|
1652
1940
|
list_id: S,
|
|
@@ -2068,7 +2356,7 @@ const TOOLS = {
|
|
|
2068
2356
|
list_table_rows: {
|
|
2069
2357
|
def: {
|
|
2070
2358
|
description:
|
|
2071
|
-
"Read a table's rows the way the grid sees them
|
|
2359
|
+
"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.",
|
|
2072
2360
|
inputSchema: obj(
|
|
2073
2361
|
{
|
|
2074
2362
|
list_id: S,
|
|
@@ -2076,7 +2364,7 @@ const TOOLS = {
|
|
|
2076
2364
|
limit: N,
|
|
2077
2365
|
sort: S,
|
|
2078
2366
|
q: S,
|
|
2079
|
-
filter: { ...O, description: 'FilterExpr JSON (object)
|
|
2367
|
+
filter: { ...O, description: 'FilterExpr JSON (object): server-side row filter; adds total_filtered.' },
|
|
2080
2368
|
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.' },
|
|
2081
2369
|
},
|
|
2082
2370
|
['list_id'],
|
|
@@ -2100,7 +2388,7 @@ const TOOLS = {
|
|
|
2100
2388
|
list_table_views: {
|
|
2101
2389
|
def: {
|
|
2102
2390
|
description:
|
|
2103
|
-
"List a table's views
|
|
2391
|
+
"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).",
|
|
2104
2392
|
inputSchema: obj({ list_id: S }, ['list_id']),
|
|
2105
2393
|
},
|
|
2106
2394
|
run: (a) =>
|
|
@@ -2156,7 +2444,7 @@ const TOOLS = {
|
|
|
2156
2444
|
update_table_view: {
|
|
2157
2445
|
def: {
|
|
2158
2446
|
description:
|
|
2159
|
-
'Update a saved table view
|
|
2447
|
+
'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.',
|
|
2160
2448
|
inputSchema: obj(
|
|
2161
2449
|
{ list_id: S, view_id: S, name: S, description: S, filter_expression: O, sorts: ARR(O), column_state: O, row_window: O },
|
|
2162
2450
|
['list_id', 'view_id'],
|
|
@@ -2173,7 +2461,7 @@ const TOOLS = {
|
|
|
2173
2461
|
delete_table_view: {
|
|
2174
2462
|
def: {
|
|
2175
2463
|
description:
|
|
2176
|
-
'Delete a saved table view
|
|
2464
|
+
'Delete a saved table view. Rows in the table are unaffected. Confirm with the user first. System views cannot be deleted.',
|
|
2177
2465
|
inputSchema: obj({ list_id: S, view_id: S }, ['list_id', 'view_id']),
|
|
2178
2466
|
},
|
|
2179
2467
|
run: (a) =>
|
|
@@ -2184,7 +2472,7 @@ const TOOLS = {
|
|
|
2184
2472
|
estimate_table_column: {
|
|
2185
2473
|
def: {
|
|
2186
2474
|
description:
|
|
2187
|
-
"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
|
|
2475
|
+
"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.",
|
|
2188
2476
|
inputSchema: obj(
|
|
2189
2477
|
{ list_id: S, column_id: S, view_id: S, selection: O, cell_filter: S, n_rows: N, start_row: N, only_failed: B },
|
|
2190
2478
|
['list_id', 'column_id'],
|
|
@@ -2198,7 +2486,7 @@ const TOOLS = {
|
|
|
2198
2486
|
run_table_column: {
|
|
2199
2487
|
def: {
|
|
2200
2488
|
description:
|
|
2201
|
-
"SPENDS vendor credits
|
|
2489
|
+
"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.",
|
|
2202
2490
|
inputSchema: obj(
|
|
2203
2491
|
{ list_id: S, column_id: S, view_id: S, selection: O, cell_filter: S, n_rows: N, start_row: N, only_failed: B },
|
|
2204
2492
|
['list_id', 'column_id'],
|
|
@@ -2212,7 +2500,7 @@ const TOOLS = {
|
|
|
2212
2500
|
run_scope_summary: {
|
|
2213
2501
|
def: {
|
|
2214
2502
|
description:
|
|
2215
|
-
'FREE counts + estimates for a column run scope
|
|
2503
|
+
'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.',
|
|
2216
2504
|
inputSchema: obj({ list_id: S, column_id: S, view_id: S, selection: O }, ['list_id', 'column_id']),
|
|
2217
2505
|
},
|
|
2218
2506
|
run: (a) =>
|
|
@@ -2226,7 +2514,7 @@ const TOOLS = {
|
|
|
2226
2514
|
estimate_table_run_all: {
|
|
2227
2515
|
def: {
|
|
2228
2516
|
description:
|
|
2229
|
-
'FREE per-column preview + total for running EVERY enrichment column in dependency order, optionally scoped (view_id + selection). Show before run_table_all
|
|
2517
|
+
'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.',
|
|
2230
2518
|
inputSchema: obj(
|
|
2231
2519
|
{ list_id: S, column_ids: ARR(S), view_id: S, selection: O, only_empty: B },
|
|
2232
2520
|
['list_id'],
|
|
@@ -2245,7 +2533,7 @@ const TOOLS = {
|
|
|
2245
2533
|
run_table_all: {
|
|
2246
2534
|
def: {
|
|
2247
2535
|
description:
|
|
2248
|
-
'SPENDS vendor credits
|
|
2536
|
+
'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.',
|
|
2249
2537
|
inputSchema: obj(
|
|
2250
2538
|
{ list_id: S, column_ids: ARR(S), view_id: S, selection: O, only_empty: B },
|
|
2251
2539
|
['list_id'],
|
|
@@ -2275,7 +2563,7 @@ const TOOLS = {
|
|
|
2275
2563
|
remove_table_rows: {
|
|
2276
2564
|
def: {
|
|
2277
2565
|
description:
|
|
2278
|
-
'Remove rows from a Scrapeloop table (list) by lead id
|
|
2566
|
+
'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.',
|
|
2279
2567
|
inputSchema: obj({ list_id: S, lead_ids: ARR(S) }, ['list_id', 'lead_ids']),
|
|
2280
2568
|
},
|
|
2281
2569
|
run: (a) =>
|
|
@@ -2364,7 +2652,7 @@ const TOOLS = {
|
|
|
2364
2652
|
delete_list: {
|
|
2365
2653
|
def: {
|
|
2366
2654
|
description:
|
|
2367
|
-
'Delete a list (the list + its membership; the underlying leads stay in the workspace). Works for any Scrapeloop list. Irreversible
|
|
2655
|
+
'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.',
|
|
2368
2656
|
inputSchema: obj({ list_id: S }, ['list_id']),
|
|
2369
2657
|
},
|
|
2370
2658
|
run: (a) =>
|
|
@@ -2375,7 +2663,7 @@ const TOOLS = {
|
|
|
2375
2663
|
delete_campaign: {
|
|
2376
2664
|
def: {
|
|
2377
2665
|
description:
|
|
2378
|
-
'Delete a campaign (the Scrapeloop campaign + its ledger; the Instantly campaign itself is not deleted). Irreversible
|
|
2666
|
+
'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.',
|
|
2379
2667
|
inputSchema: obj({ campaign_id: S }, ['campaign_id']),
|
|
2380
2668
|
},
|
|
2381
2669
|
run: (a) =>
|
|
@@ -2388,7 +2676,7 @@ const TOOLS = {
|
|
|
2388
2676
|
list_replies: {
|
|
2389
2677
|
def: {
|
|
2390
2678
|
description:
|
|
2391
|
-
'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
|
|
2679
|
+
'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).',
|
|
2392
2680
|
inputSchema: obj({ sentiment: S, handled: B, lead_id: S, campaign_id: S, status: S, limit: N, offset: N }),
|
|
2393
2681
|
},
|
|
2394
2682
|
run: (a) =>
|
|
@@ -2408,7 +2696,7 @@ const TOOLS = {
|
|
|
2408
2696
|
update_reply: {
|
|
2409
2697
|
def: {
|
|
2410
2698
|
description:
|
|
2411
|
-
"Update a reply: mark it handled/unhandled and/or set its sentiment. Setting sentiment is a human classification
|
|
2699
|
+
"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).",
|
|
2412
2700
|
inputSchema: obj(
|
|
2413
2701
|
{
|
|
2414
2702
|
reply_id: S,
|
|
@@ -2519,7 +2807,7 @@ const TOOLS = {
|
|
|
2519
2807
|
{
|
|
2520
2808
|
slug: S,
|
|
2521
2809
|
status: { ...S, enum: ['planned', 'in_progress', 'deployed'] },
|
|
2522
|
-
pr_url: { ...S, description: 'PR link
|
|
2810
|
+
pr_url: { ...S, description: 'PR link: set it when marking deployed' },
|
|
2523
2811
|
},
|
|
2524
2812
|
['slug', 'status'],
|
|
2525
2813
|
),
|
|
@@ -2569,7 +2857,7 @@ const TOOLS = {
|
|
|
2569
2857
|
roadmap_update_plan: {
|
|
2570
2858
|
def: {
|
|
2571
2859
|
description:
|
|
2572
|
-
"Update a roadmap card's fields
|
|
2860
|
+
"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.",
|
|
2573
2861
|
inputSchema: obj(
|
|
2574
2862
|
{
|
|
2575
2863
|
slug: S,
|
|
@@ -2595,11 +2883,13 @@ const TOOLS = {
|
|
|
2595
2883
|
);
|
|
2596
2884
|
return Object.keys(body).length
|
|
2597
2885
|
? api('PATCH', `/roadmap/${enc(slug)}`, body)
|
|
2598
|
-
: { ok: false, status: 400, error: { detail: 'nothing to update
|
|
2886
|
+
: { ok: false, status: 400, error: { detail: 'nothing to update: pass at least one field' } };
|
|
2599
2887
|
},
|
|
2600
2888
|
},
|
|
2601
2889
|
};
|
|
2602
2890
|
|
|
2891
|
+
TOOLS.preview_pipeline.def.inputSchema = TOOLS.create_pipeline.def.inputSchema;
|
|
2892
|
+
|
|
2603
2893
|
const extractHttpMethods = (run) => [
|
|
2604
2894
|
...run.toString().matchAll(/api\(\s*['"](GET|POST|PUT|PATCH|DELETE)['"]/g),
|
|
2605
2895
|
].map((match) => match[1]);
|
|
@@ -2644,19 +2934,19 @@ for (const [name, tool] of Object.entries(TOOLS)) {
|
|
|
2644
2934
|
}
|
|
2645
2935
|
|
|
2646
2936
|
async function serve() {
|
|
2647
|
-
// Start cleanly even without a key
|
|
2937
|
+
// Start cleanly even without a key: never crash or hang. The first tools/call
|
|
2648
2938
|
// returns a clear, structured auth error (api() handles the missing key), and we
|
|
2649
2939
|
// log exactly one warning to stderr here. The key itself is never logged.
|
|
2650
2940
|
if (!API_KEY) {
|
|
2651
2941
|
warnStartupOnce(
|
|
2652
2942
|
'missing_api_key',
|
|
2653
|
-
'scrapeloop-mcp: SCRAPELOOP_API_KEY is not set
|
|
2943
|
+
'scrapeloop-mcp: SCRAPELOOP_API_KEY is not set, so tools will return an auth error until it is. ' +
|
|
2654
2944
|
'Generate a key in Scrapeloop → Settings → API access.'
|
|
2655
2945
|
);
|
|
2656
2946
|
}
|
|
2657
2947
|
|
|
2658
2948
|
const server = new Server(
|
|
2659
|
-
{ name: 'scrapeloop-mcp', version: '0.
|
|
2949
|
+
{ name: 'scrapeloop-mcp', version: '0.9.0' },
|
|
2660
2950
|
{ capabilities: { tools: {} } }
|
|
2661
2951
|
);
|
|
2662
2952
|
|
|
@@ -2697,8 +2987,8 @@ async function serve() {
|
|
|
2697
2987
|
// Exported for the manifest/tools drift test. Tests set SCRAPELOOP_MCP_NO_SERVE
|
|
2698
2988
|
// before importing so the stdio server (and the API-key requirement) stay dormant;
|
|
2699
2989
|
// the CLI leaves it unset and serves. (An entrypoint-URL check is unreliable when
|
|
2700
|
-
// the install path contains spaces
|
|
2701
|
-
// process.argv[1] does not
|
|
2990
|
+
// the install path contains spaces: import.meta.url percent-encodes them but
|
|
2991
|
+
// process.argv[1] does not, so an explicit opt-out flag is used instead.)
|
|
2702
2992
|
export {
|
|
2703
2993
|
MUTATION_POLICIES,
|
|
2704
2994
|
TOOLS,
|
package/src/mutation-policies.js
CHANGED
|
@@ -40,9 +40,15 @@ export const MUTATION_POLICIES = Object.freeze({
|
|
|
40
40
|
create_integration: keyed('POST', ['list_integrations'], ['vendor', 'plugin_kind']),
|
|
41
41
|
add_credential: reconcile('POST', ['list_credentials'], ['integration_id', 'label']),
|
|
42
42
|
test_credential: idempotent('POST', ['list_credentials'], ['credential_id']),
|
|
43
|
+
preview_pipeline: read(),
|
|
44
|
+
preview_scrape_plan: read(),
|
|
45
|
+
create_scrape_plan: keyed('POST', ['list_scrape_plans'], ['name', 'target_list_id', 'kind']),
|
|
46
|
+
pause_scrape_plan: never('POST', ['get_scrape_plan'], ['plan_id']),
|
|
47
|
+
start_scrape_plan: never('POST', ['get_scrape_plan'], ['plan_id']),
|
|
43
48
|
estimate_scrape: read(),
|
|
44
49
|
submit_scrape: keyed('POST', ['list_jobs'], ['kind', 'integration_id', 'credential_id', 'table_id']),
|
|
45
50
|
cancel_job: never('POST', ['get_job'], ['job_id', 'status']),
|
|
51
|
+
attach_job_to_table: idempotent('POST', ['get_job'], ['job_id']),
|
|
46
52
|
search_leads: read(),
|
|
47
53
|
estimate_leads: read(),
|
|
48
54
|
expand_job_titles: read(),
|
|
@@ -65,6 +71,8 @@ export const MUTATION_POLICIES = Object.freeze({
|
|
|
65
71
|
run_enrich: never('POST', ['list_jobs'], ['preset_slug', 'preset_id', 'lead_ids', 'list_id']),
|
|
66
72
|
test_recipe: never('POST', ['get_credits'], ['lead_id']),
|
|
67
73
|
update_ai_settings: idempotent('PUT', ['get_ai_settings'], ['default_ai_model', 'enabled_models']),
|
|
74
|
+
update_playbook: idempotent('PUT', ['get_playbook'], ['channels', 'scrape', 'tables', 'checks', 'routing', 'campaigns']),
|
|
75
|
+
set_playbook_niches: idempotent('PUT', ['get_playbook'], ['niches']),
|
|
68
76
|
update_ai_context: idempotent('PUT', ['get_ai_context'], ['company_description', 'icp']),
|
|
69
77
|
generate_ai_context: never('POST', ['get_ai_context', 'get_credits'], ['domain']),
|
|
70
78
|
create_strategy: reconcile('POST', ['list_strategies'], ['name']),
|
|
@@ -82,8 +90,19 @@ export const MUTATION_POLICIES = Object.freeze({
|
|
|
82
90
|
pause_campaign: reconcile('POST', ['list_campaigns'], ['campaign_id', 'status']),
|
|
83
91
|
sync_campaign: idempotent('POST', ['campaign_stats'], ['campaign_id']),
|
|
84
92
|
update_sender_config: idempotent('PUT', ['get_sender_config'], ['credential_id']),
|
|
93
|
+
// A full-document PUT: sending it twice leaves the campaign in the same state
|
|
94
|
+
// as sending it once, so a lost response is safe to retry.
|
|
95
|
+
create_sender_fields: idempotent(
|
|
96
|
+
'POST', ['get_campaign_field_mapping'], ['campaign_id', 'names'],
|
|
97
|
+
),
|
|
98
|
+
set_campaign_field_mapping: idempotent(
|
|
99
|
+
'PUT',
|
|
100
|
+
['get_campaign_field_mapping'],
|
|
101
|
+
['campaign_id'],
|
|
102
|
+
),
|
|
85
103
|
create_list: keyed('POST', ['get_lists'], ['name']),
|
|
86
104
|
add_list_members: idempotent('POST', ['get_list_rows'], ['list_id', 'global_person_ids']),
|
|
105
|
+
create_pipeline: idempotent('POST', ['get_pipeline'], ['operation_id']),
|
|
87
106
|
create_workbook: reconcile('POST', ['list_workbooks'], ['name', 'list_ids']),
|
|
88
107
|
add_table_to_workbook: reconcile(
|
|
89
108
|
'POST',
|
|
@@ -123,6 +142,14 @@ export const MUTATION_POLICIES = Object.freeze({
|
|
|
123
142
|
['get_table_columns'],
|
|
124
143
|
['table_id', 'preset_slug', 'preset_id', 'label', 'operation_id'],
|
|
125
144
|
),
|
|
145
|
+
// High-value create: with auto_run it starts a credit-billed backfill, and API
|
|
146
|
+
// idempotency is per (list_id, client_operation_id), so a fresh key on retry
|
|
147
|
+
// would make a second column AND a second paid backfill. One durable key.
|
|
148
|
+
create_table_native_column: keyed(
|
|
149
|
+
'POST',
|
|
150
|
+
['get_table_columns', 'get_table_native_tools'],
|
|
151
|
+
['table_id', 'tool', 'label', 'operation_id'],
|
|
152
|
+
),
|
|
126
153
|
set_table_cells: idempotent('POST', ['list_table_rows'], ['table_id']),
|
|
127
154
|
import_leads: idempotent(
|
|
128
155
|
'POST',
|