scrapeloop-mcp 0.33.0 → 0.34.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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "scrapeloop-mcp",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
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_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 && node test/mcp_server_version.mjs",
7
+ "test": "node --check src/index.js && node test/mcp_table_tools.mjs && node test/mcp_playbook_plans.mjs && node test/mcp_campaign_setup.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 && node test/mcp_server_version.mjs",
8
8
  "release:preflight": "node scripts/release-preflight.mjs",
9
9
  "release:verify": "node scripts/release-installed-verify.mjs"
10
10
  },
package/src/index.js CHANGED
@@ -30,6 +30,8 @@ const API_KEY = process.env.SCRAPELOOP_API_KEY;
30
30
  const BASE = (process.env.SCRAPELOOP_API_URL || 'https://api.scrapeloop.com').replace(/\/$/, '');
31
31
  const TIMEOUT_MS = Number(process.env.SCRAPELOOP_TIMEOUT_MS) || 30000;
32
32
  const MAX_RETRIES = 3;
33
+ // The API sits behind a proxy that ends a request after 100 seconds.
34
+ const DEEP_READ = { timeoutMs: Math.max(TIMEOUT_MS, 90000), maxRetries: 1 };
33
35
  const MAX_TABLE_CELL_WRITES = 500;
34
36
  const MAX_TABLE_CELL_REQUEST_BYTES = 256 * 1024;
35
37
  // Native Smart Table tool keys, mirroring apps/api/lib/native_tool_columns.json.
@@ -48,6 +50,7 @@ const NATIVE_TABLE_TOOLS = Object.freeze([
48
50
  'business_email_finder',
49
51
  'linkedin_finder',
50
52
  'linkedin_activity',
53
+ 'google_profile',
51
54
  ]);
52
55
  const TRACE_HEADER = 'X-Scrapeloop-Trace-Id';
53
56
  const SAFE_TRACE_ID = /^[A-Za-z0-9_-]{8,80}$/;
@@ -133,6 +136,9 @@ async function api(method, path, body, options = {}) {
133
136
  || policy?.retry === 'idempotent'
134
137
  || policy?.retry === 'idempotency_key';
135
138
  const maxRetries = canRetry ? (options.maxRetries ?? MAX_RETRIES) : 0;
139
+ // A few deep reads ask the sender and walk a whole campaign. They may take
140
+ // longer than an ordinary call, and are not worth sending three more times.
141
+ const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
136
142
  const headers = {
137
143
  Authorization: `Bearer ${API_KEY}`,
138
144
  'Content-Type': 'application/json',
@@ -149,7 +155,7 @@ async function api(method, path, body, options = {}) {
149
155
  method: verb,
150
156
  headers,
151
157
  body: body === undefined ? undefined : JSON.stringify(body),
152
- signal: AbortSignal.timeout(TIMEOUT_MS),
158
+ signal: AbortSignal.timeout(timeoutMs),
153
159
  });
154
160
  } catch (e) {
155
161
  lastErr = e;
@@ -164,7 +170,7 @@ async function api(method, path, body, options = {}) {
164
170
  return {
165
171
  ok: false,
166
172
  status: 0,
167
- error: { detail: `Could not reach Scrapeloop (${timedOut ? `timeout after ${TIMEOUT_MS}ms` : String(e)}).` },
173
+ error: { detail: `Could not reach Scrapeloop (${timedOut ? `timeout after ${timeoutMs}ms` : String(e)}).` },
168
174
  ...(options.captureTrace
169
175
  ? { trace_id: traceId, network_error: timedOut ? 'timeout' : 'transport' }
170
176
  : {}),
@@ -575,14 +581,15 @@ const TOOLS = {
575
581
  run: (a) => api('GET', `/plans/${enc(a.plan_id)}/yield`),
576
582
  },
577
583
  set_scrape_plan_ready_goal: {
578
- def: { description: 'Save a verified-email goal on a native Maps draft with optional whole-scope repeat searches after the saved delay. Choose the ready Table and its email campaign, monthly goal/minimum, optional hard-maximum mode, unused-stock ceiling and evidence rules. Pass the reviewed expected_updated_at from get_scrape_plan. Counts require current input-bound verification, qualification, campaign fields and sender-history checks. This does not start scraping, verification or campaigns. Complete-search pacing must be available before Start.', inputSchema: obj({
584
+ def: { description: 'Save a verified-email goal on a native Maps draft with optional whole-scope repeat searches after the saved delay. Choose the ready Table and its email campaign, monthly goal/minimum, optional hard-maximum mode, unused-stock ceiling and evidence rules. supply_mode on_demand searches only while the linked campaign has room (plus buffer_days of its daily intake); only then may monthly_goal be null, with limit_mode target and monthly_minimum 0. allow_role counts an exact Role result (info@, office@) as ready and allow_resolved_catchall counts a catch-all that passed the second check: both must match the Table rules and the campaign, which check_campaign_setup verifies. Always send monthly_minimum and timezone: their defaults suit one business only. Pass the reviewed expected_updated_at from get_scrape_plan. Counts require current input-bound verification, qualification, campaign fields and sender-history checks. This does not start scraping, verification or campaigns. Complete-search pacing must be available before Start.', inputSchema: obj({
579
585
  plan_id: { ...S, format: 'uuid' }, expected_updated_at: { ...S, format: 'date-time' },
580
586
  goal: obj({ kind: { ...S, enum: ['verified_email'] }, ready_list_id: { ...S, format: 'uuid' },
581
- campaign_id: { ...S, format: 'uuid' }, monthly_goal: { type: 'integer', minimum: 1, maximum: 1000000 },
587
+ campaign_id: { ...S, format: 'uuid' }, monthly_goal: { type: ['integer', 'null'], minimum: 1, maximum: 1000000 },
582
588
  limit_mode: { ...S, enum: ['target', 'maximum'] },
589
+ supply_mode: { ...S, enum: ['monthly', 'on_demand'] }, buffer_days: { type: 'integer', minimum: 0, maximum: 30 },
583
590
  monthly_minimum: { type: 'integer', minimum: 0, maximum: 1000000 },
584
591
  stock_ceiling: { type: 'integer', minimum: 1, maximum: 1000000 }, timezone: S,
585
- evidence_max_age_days: { type: 'integer', minimum: 1, maximum: 90 }, allow_resolved_catchall: B,
592
+ evidence_max_age_days: { type: 'integer', minimum: 1, maximum: 90 }, allow_resolved_catchall: B, allow_role: B,
586
593
  required_fields: ARR({ ...S, enum: ['name', 'organization_name', 'first_name', 'last_name', 'industry', 'city', 'state', 'postal_code', 'domain', 'language'] }),
587
594
  languages: ARR({ ...S, pattern: '^[a-z]{2}$' }),
588
595
  }, ['ready_list_id', 'campaign_id', 'monthly_goal', 'monthly_minimum', 'stock_ceiling', 'timezone']),
@@ -594,10 +601,10 @@ const TOOLS = {
594
601
  run: (a) => api('GET', `/plans/${enc(a.plan_id)}/budget`),
595
602
  },
596
603
  set_scrape_plan_budget: {
597
- def: { description: 'Save reviewed credit limits and Table ownership for a paused complete-search plan with a ready-email goal. Monthly lead credits always need a numeric cap. Explicit null removes only the extra lifetime lead or monthly/lifetime verify cap; omission is rejected. Zero blocks spending. No-cap checks still require allowed tools, bounded jobs and available workspace credits. Never infer no cap without approval. Include Raw, Email ready and every check Table. One Table belongs to one budget. Preserve expected_revision from the current budget; omit it only for the first save. A lost reply requires readback before retry. This starts no work and does not enable checks. website_language permits the free bounded Website languages preset with a saved attempt. owner_website explicitly permits the free bounded system Owner name finder; it still needs a saved job and attempt. review_history permits only the system Google review stats preset with source explicitly outscraper and a separate review_budget in USD, default zero. Select an existing connection from get_scrape_plan_budget.review_connections. Omitted review_budget preserves saved settings. Each row reserves 0.147 USD; recorded units use the published ceiling, not a confirmed account invoice. Unknown requests stay held and never retry. Google-first, custom recipes and other paid vendors remain blocked. Use review_complete_scrape_plan to check readiness before requesting exact launch approval.', inputSchema: obj({
604
+ def: { description: 'Save reviewed credit limits and Table ownership for a paused complete-search plan with a ready-email goal. All four credit answers are required. A number is a limit; explicit null means no plan limit for that one (then only the workspace balance stops the plan), and omission is rejected. Zero blocks spending. No-cap checks still require allowed tools, bounded jobs and available workspace credits. Never infer no cap without approval. Include Raw, Email ready and every check Table. One Table belongs to one budget. Preserve expected_revision from the current budget; omit it only for the first save. A lost reply requires readback before retry. This starts no work and does not enable checks. website_language permits the free bounded Website languages preset with a saved attempt. owner_website explicitly permits the free bounded system Owner name finder; it still needs a saved job and attempt. review_history permits only the system Google review stats preset with source explicitly outscraper and a separate review_budget in USD, default zero. Select an existing connection from get_scrape_plan_budget.review_connections. Omitted review_budget preserves saved settings. Each row reserves 0.147 USD; recorded units use the published ceiling, not a confirmed account invoice. Unknown requests stay held and never retry. Google-first, custom recipes and other paid vendors remain blocked. Use review_complete_scrape_plan to check readiness before requesting exact launch approval.', inputSchema: obj({
598
605
  plan_id: { ...S, format: 'uuid' }, expected_revision: { ...S, format: 'uuid' },
599
606
  limits: obj({
600
- monthly_lead_credits: { type: 'integer', minimum: 0, maximum: 1000000000 },
607
+ monthly_lead_credits: { type: ['integer', 'null'], minimum: 0, maximum: 1000000000 },
601
608
  lifetime_lead_credits: { type: ['integer', 'null'], minimum: 0, maximum: 1000000000 },
602
609
  monthly_verify_credits: { type: ['integer', 'null'], minimum: 0, maximum: 1000000000 },
603
610
  lifetime_verify_credits: { type: ['integer', 'null'], minimum: 0, maximum: 1000000000 },
@@ -608,6 +615,40 @@ const TOOLS = {
608
615
  }, ['plan_id', 'limits', 'list_ids']) },
609
616
  run: ({ plan_id, ...body }) => api('PUT', `/plans/${enc(plan_id)}/budget`, body),
610
617
  },
618
+ set_scrape_plan_supply: {
619
+ def: { description: 'Change when a paused complete plan searches, and whether it counts role addresses. supply_mode monthly works toward monthly_goal; on_demand searches only while the linked campaign has room plus buffer_days of its daily intake. monthly_goal null (no monthly ready-lead limit) is allowed only with on_demand, limit_mode target and monthly_minimum 0. allow_role true counts an exact Role result (info@, office@) as ready; omit it to keep the saved choice. Always send buffer_days and monthly_minimum: left out, they are reset to 3 and 0. The scope, ready count, campaign and credit limits stay. Pass expected_updated_at from get_scrape_plan read after the pause. The plan then needs review_complete_scrape_plan and launch_complete_scrape_plan again. This starts no search.', inputSchema: obj({
620
+ plan_id: { ...S, format: 'uuid' }, expected_updated_at: { ...S, format: 'date-time' },
621
+ supply_mode: { ...S, enum: ['monthly', 'on_demand'] }, buffer_days: { type: 'integer', minimum: 0, maximum: 30 },
622
+ monthly_goal: { type: ['integer', 'null'], minimum: 1, maximum: 1000000 },
623
+ limit_mode: { ...S, enum: ['target', 'maximum'] },
624
+ monthly_minimum: { type: 'integer', minimum: 0, maximum: 1000000 }, allow_role: B,
625
+ }, ['plan_id', 'expected_updated_at', 'supply_mode', 'buffer_days', 'monthly_goal', 'limit_mode', 'monthly_minimum']) },
626
+ run: ({ plan_id, ...body }) => api('PUT', `/plans/${enc(plan_id)}/ready/supply`, body),
627
+ },
628
+ switch_scrape_plan_campaign: {
629
+ def: { description: 'Point a paused complete plan at another email campaign that reads the same ready Table. The plan must be paused by a person, both campaigns must have filling off, the new campaign must be an open Instantly campaign, and no check or Table move may be running (otherwise 409: wait and try again). The ready count, the searches done and the credit limits stay. Afterwards switch filling on for the new campaign, then review and launch the plan again. This uploads nothing and starts no search.', inputSchema: obj({
630
+ plan_id: { ...S, format: 'uuid' }, expected_updated_at: { ...S, format: 'date-time' }, campaign_id: { ...S, format: 'uuid' },
631
+ }, ['plan_id', 'expected_updated_at', 'campaign_id']) },
632
+ run: ({ plan_id, ...body }) => api('PUT', `/plans/${enc(plan_id)}/ready/campaign`, body),
633
+ },
634
+ update_scrape_plan: {
635
+ def: { description: 'Switch Google review counting on or off for the next searches of a Scrapeloop Maps plan. Needed when a Table rule asks for the review count. The plan must be paused, and a complete plan must have no place half searched. A complete plan then needs review_complete_scrape_plan and launch_complete_scrape_plan again. Limits and search area are not changed by this tool.', inputSchema: obj({
636
+ plan_id: { ...S, format: 'uuid' }, count_reviews: B,
637
+ }, ['plan_id', 'count_reviews']) },
638
+ run: ({ plan_id, ...body }) => api('PATCH', `/plans/${enc(plan_id)}`, body),
639
+ },
640
+ check_campaign_setup: {
641
+ def: { description: 'One free read that walks a whole self-filling campaign and says whether its parts fit each other: the search plan, the Tables and rules on the way, the checks, the email answer (Table rule, plan goal and campaign must agree), the campaign, the credit limits and the approval. Pass plan_id for a search plan, or campaign_id to start from a campaign (works with no plan too). Returns fits (true only when nothing is fail and nothing is unknown), counts, and checks in the order a lead travels; each has step, key, status (ok, warn, fail or unknown), a plain title and detail, and often fixes: the exact tool and args that end it. Run it after every build step and before asking for a start. Apply a fix only as its note says, ask the person before any fix that changes who gets an email or what may be spent, and run this again afterwards. It changes nothing, starts nothing and approves nothing. deep=false skips the sender read.', inputSchema: obj({
642
+ plan_id: { ...S, format: 'uuid' }, campaign_id: { ...S, format: 'uuid' }, deep: B,
643
+ }) },
644
+ run: (a) => {
645
+ if (!!a.plan_id === !!a.campaign_id) {
646
+ return { ok: false, status: 400, error: { detail: 'Pass plan_id or campaign_id, one of the two.' } };
647
+ }
648
+ const path = a.plan_id ? `/plans/${enc(a.plan_id)}/setup-check` : `/campaigns/${enc(a.campaign_id)}/setup-check`;
649
+ return api('GET', a.deep === false ? `${path}?deep=false` : path, undefined, a.deep === false ? {} : DEEP_READ);
650
+ },
651
+ },
611
652
  review_complete_scrape_plan: {
612
653
  def: { description: 'Read the exact complete-search scope, ready-output goal, Table rules, shared limits, repeat delay and current launch blockers. Free read. This does not start source work, checks or campaigns.', inputSchema: obj({ plan_id: { ...S, format: 'uuid' } }, ['plan_id']) },
613
654
  run: (a) => api('GET', `/plans/${enc(a.plan_id)}/launch-review`),
@@ -1070,7 +1111,7 @@ const TOOLS = {
1070
1111
  },
1071
1112
  get_campaign_handoff_status: {
1072
1113
  def: {
1073
- description: 'Read waiting, completed and held review Table handoffs for a campaign. No provider call, retry or write. Held copies may already have happened; inspect before moving again.',
1114
+ description: 'Read waiting, completed and held review Table handoffs for a campaign. No provider call, retry or write. Held copies may already have happened; inspect before moving again. business_replied counts leads that were not moved because another address of the same business already replied or asked to stop; those need no review.',
1074
1115
  inputSchema: obj({ campaign_id: S }, ['campaign_id']),
1075
1116
  },
1076
1117
  run: (a) => a.campaign_id ? api('GET', `/campaigns/${enc(a.campaign_id)}/handoffs`)
@@ -1162,7 +1203,7 @@ const TOOLS = {
1162
1203
  run: (a) => api('GET', `/campaigns/${enc(a.campaign_id)}/qualification`),
1163
1204
  },
1164
1205
  update_campaign: {
1165
- 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. eligibility_rule uses {source_list_id, filter, optional excluded_campaign_ids} for Current qualification, or null to turn it off. The optional campaign IDs hold businesses already in those campaigns by Google place ID. qualification: {} disables the Belgium website language gate; {rule: be_dutch, evidence_column_id: UUID, max_age_days: 90} enables it. Read back after saving. These settings do not start checks or remove sender leads.", properties: { eligibility_rule: { type: ["object", "null"], additionalProperties: false, required: ["source_list_id", "filter"], properties: { source_list_id: S, filter: O, excluded_campaign_ids: { type: 'array', items: S, maxItems: 20 } } }, qualification: { ...O, description: "Belgium website language rule, or {} to disable." }, 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']) },
1206
+ 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. eligibility_rule uses {source_list_id, filter, optional excluded_campaign_ids} for Current qualification, or null to turn it off. The optional campaign IDs hold businesses already in those campaigns by Google place ID. An edit that leaves excluded_campaign_ids out keeps the saved campaigns; send [] to clear them. qualification: {} disables the Belgium website language gate; {rule: be_dutch, evidence_column_id: UUID, max_age_days: 90} enables it. Read back after saving. These settings do not start checks or remove sender leads.", properties: { eligibility_rule: { type: ["object", "null"], additionalProperties: false, required: ["source_list_id", "filter"], properties: { source_list_id: S, filter: O, excluded_campaign_ids: { type: 'array', items: S, maxItems: 20 } } }, qualification: { ...O, description: "Belgium website language rule, or {} to disable." }, 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']) },
1166
1207
  run: (a) => api('PATCH', `/campaigns/${enc(a.campaign_id)}`, a.patch || {}),
1167
1208
  },
1168
1209
  duplicate_campaign: {
@@ -1234,7 +1275,7 @@ const TOOLS = {
1234
1275
  '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.',
1235
1276
  inputSchema: obj({ campaign_id: S }, ['campaign_id']),
1236
1277
  },
1237
- run: (a) => api('GET', `/campaigns/${enc(a.campaign_id)}/readiness`),
1278
+ run: (a) => api('GET', `/campaigns/${enc(a.campaign_id)}/readiness`, undefined, DEEP_READ),
1238
1279
  },
1239
1280
  get_sender_config: {
1240
1281
  def: { description: "Get an Instantly credential's sender config (daily cap, send accounts, …).", inputSchema: obj({ credential_id: S }, ['credential_id']) },
@@ -1685,7 +1726,7 @@ const TOOLS = {
1685
1726
  create_table_native_column: {
1686
1727
  def: {
1687
1728
  description:
1688
- `Attach one native Scrapeloop tool as a real Smart Table column: ${NATIVE_TABLE_TOOLS.join(', ')}. These are Scrapeloop's first-party tools, several of which superseded 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, each email finder reserves 3, linkedin_finder reserves 5, and linkedin_activity reserves 3. Settlement charges only fresh vendor checks and returns the unused reservation; missing inputs, cached rows, and skipped rows are free. linkedin_finder charges its 5 credits only for a row where it found the owner's LinkedIn profile; a row with no owner found is free. linkedin_activity reads each row's personal LinkedIn profile and newest post and labels it Active, Maybe or Weak (cell text), so LinkedIn invites go to people likely to answer; it charges 3 credits only for a profile it read, and rows with no personal profile, cached rows and profiles that no longer exist 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.`,
1729
+ `Attach one native Scrapeloop tool as a real Smart Table column: ${NATIVE_TABLE_TOOLS.join(', ')}. These are Scrapeloop's first-party tools, several of which superseded 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, each email finder reserves 3, linkedin_finder reserves 5, linkedin_activity reserves 3, and google_profile reserves 1. Settlement charges only fresh vendor checks and returns the unused reservation; missing inputs, cached rows, and skipped rows are free. linkedin_finder charges its 5 credits only for a row where it found the owner's LinkedIn profile; a row with no owner found is free. linkedin_activity reads each row's personal LinkedIn profile and newest post and labels it Active, Maybe or Weak (cell text), so LinkedIn invites go to people likely to answer; it charges 3 credits only for a profile it read, and rows with no personal profile, cached rows and profiles that no longer exist are free. google_profile finds a business on Google Maps for a lead that did not come from Google Maps (a Yelp lead, an imported list): it searches the business name in its town, accepts a result only when the phone, the website or the exact name in the same town matches, and saves place_id, gbp_url, rating and reviews_count on the cell (status found, not_found or unclear) and the place ID on a lead that had none; it charges 1 credit only for a profile found, and a business not on Google, more than one matching profile and cached rows are free. Filter on col.<key>.reviews_count to keep small businesses; a business with no reviews yet is saved as 0, and an unknown count has no number and never passes a numeric rule. 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.`,
1689
1730
  inputSchema: obj(
1690
1731
  {
1691
1732
  table_id: S,
@@ -1702,7 +1743,7 @@ const TOOLS = {
1702
1743
  options: {
1703
1744
  ...O,
1704
1745
  description:
1705
- 'Tool-specific options. verify_phone: phone_col, default_region. email_finder: owner_name_column. business_email_finder: owner_email_column. linkedin_finder: business_col. linkedin_activity: linkedin_col, business_col. An undocumented name is rejected, never ignored.',
1746
+ 'Tool-specific options. verify_phone: phone_col, default_region. email_finder: owner_name_column. business_email_finder: owner_email_column. linkedin_finder: business_col. linkedin_activity: linkedin_col, business_col. google_profile: business_col. An undocumented name is rejected, never ignored.',
1706
1747
  },
1707
1748
  operation_id: {
1708
1749
  ...S,
@@ -2231,6 +2272,21 @@ const TOOLS = {
2231
2272
  })
2232
2273
  : { ok: false, status: 400, error: { detail: 'list_id and rule_id are required.' } },
2233
2274
  },
2275
+ update_table_flow_rule: {
2276
+ def: {
2277
+ description:
2278
+ 'Change one saved Table rule in place: its condition, name, action settings, daily limit or delay. Send only what changes. A condition replaces the whole saved condition, so start from the one list_table_flow_rules returns (or from a fix that check_campaign_setup names) and keep every "not in that Table" guard. The rule keeps its id, history and counters. On a Table that belongs to a running complete plan, pause the plan first: a changed rule needs a fresh review and launch. Use toggle_table_flow_rule to switch a rule on or off.',
2279
+ inputSchema: obj({
2280
+ list_id: S, rule_id: S, name: S, condition: { type: 'object' }, action_config: { type: 'object' },
2281
+ daily_fire_cap: { type: 'integer', minimum: 1, maximum: 100000 },
2282
+ delay_seconds: { type: 'integer', minimum: 0, maximum: 2592000 },
2283
+ }, ['list_id', 'rule_id']),
2284
+ },
2285
+ run: ({ list_id, rule_id, ...body }) =>
2286
+ Object.keys(body).length
2287
+ ? api('PATCH', `/lists/${enc(list_id)}/flow-rules/${enc(rule_id)}`, body)
2288
+ : { ok: false, status: 400, error: { detail: 'Send at least one thing to change.' } },
2289
+ },
2234
2290
  list_campaign_flow_rules: {
2235
2291
  def: {
2236
2292
  description:
@@ -2493,7 +2549,7 @@ const TOOLS = {
2493
2549
  limit: N,
2494
2550
  sort: S,
2495
2551
  q: S,
2496
- filter: { ...O, description: 'FilterExpr JSON (object): server-side row filter; adds total_filtered. lead.email_domain derives the exact lowercase domain from the current email, separate from website lead.domain. lead.gcid reads an exact Google category ID from current source data; it does not prove business fit. Use eq/any_of/none_of for exact lists; add not_empty to hold missing or malformed values. No DNS or provider lookup.' },
2552
+ filter: { ...O, description: 'FilterExpr JSON (object): server-side row filter; adds total_filtered. lead.email_domain derives the exact lowercase domain from the current email, separate from website lead.domain. lead.gcid reads an exact Google category ID from current source data; it does not prove business fit. Use eq/any_of/none_of for exact lists; add not_empty to hold missing or malformed values. No DNS or provider lookup. lead.email_check is the exact saved email check result for the current address (safe, role, catch_all, disposable, inbox_full, disabled, invalid, unknown), empty when never checked; lead.email_status risky holds both role and inbox_full, so use lead.email_check any_of ["safe","role"] for an exact rule.' },
2497
2553
  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.' },
2498
2554
  },
2499
2555
  ['list_id'],
@@ -53,6 +53,9 @@ export const MUTATION_POLICIES = Object.freeze({
53
53
  start_scrape_plan: never('POST', ['get_scrape_plan'], ['plan_id']),
54
54
  set_scrape_plan_ready_goal: reconcile('PUT', ['get_scrape_plan_ready_goal'], ['plan_id', 'goal']),
55
55
  set_scrape_plan_budget: reconcile('PUT', ['get_scrape_plan_budget'], ['plan_id', 'limits', 'list_ids']),
56
+ set_scrape_plan_supply: reconcile('PUT', ['get_scrape_plan_ready_goal'], ['plan_id', 'supply_mode', 'monthly_goal', 'limit_mode']),
57
+ switch_scrape_plan_campaign: reconcile('PUT', ['get_scrape_plan_ready_goal'], ['plan_id', 'campaign_id']),
58
+ update_scrape_plan: idempotent('PATCH', ['get_scrape_plan'], ['plan_id', 'count_reviews']),
56
59
  estimate_scrape: read(),
57
60
  submit_scrape: keyed('POST', ['list_jobs'], ['kind', 'integration_id', 'credential_id', 'table_id']),
58
61
  cancel_job: never('POST', ['get_job'], ['job_id', 'status']),
@@ -184,6 +187,7 @@ export const MUTATION_POLICIES = Object.freeze({
184
187
  save_flow_layout: idempotent('PUT', ['get_flow_graph'], ['scope_key']),
185
188
  create_table_flow_rule: reconcile('POST', ['list_table_flow_rules'], ['list_id', 'name']),
186
189
  toggle_table_flow_rule: idempotent('PATCH', ['list_table_flow_rules'], ['list_id', 'rule_id', 'enabled']),
190
+ update_table_flow_rule: idempotent('PATCH', ['list_table_flow_rules'], ['list_id', 'rule_id']),
187
191
  create_campaign_flow_rule: reconcile(
188
192
  'POST',
189
193
  ['list_campaign_flow_rules'],