Skip to main content
Glama
google-ads-write-mcp

google-ads-write-mcp

by Luxand

google-ads-write-mcp

A small, allow-listed write-side MCP server for Google Ads, plus a read-only Keyword Planner tool. It is the companion to Google's official google-ads-mcp, which is read-only by design: use that one to look things up, and this one to change them.

Works with Claude Code (and any MCP client over stdio).

Safety model

  • Dry run by default. Every mutating tool sends its request with validate_only=true — Google checks auth and payload and changes nothing. Pass confirm=true to apply.

  • Atomic. Each call is one MutateGoogleAdsRequest with partial failure off: either everything in it is created, or nothing is.

  • Additive. Tools add ads, keywords, assets, ad groups and campaigns, or toggle ENABLED/PAUSED, or tune budget and target CPA. Nothing edits existing ad text. New campaigns are created PAUSED.

  • One guarded delete. remove_entity refuses anything that does not sit under a campaign carrying the managed label (default claude-managed, which create_search_campaign attaches to every campaign it creates), and verifies that with a read query first. Removal in Google Ads is permanent; there is no restore.

  • Audited. Every call — dry run or applied, success or rejection — is appended to a local JSONL audit log with timestamp, tool, customer, payload and API result. No secrets are written.

  • Local limits. RSA headline/description counts and lengths, sitelink, callout and snippet lengths, image size and aspect ratio are checked before anything is sent.

Related MCP server: Google Ads MCP Admin

Tools

Tool

Purpose

create_search_campaign

Build a complete Search campaign in one atomic mutate: budget, campaign, location and language criteria, campaign negatives, the managed label, every ad group with its keywords, optional ad-group negatives, optional final URL suffix (UTMs) and one responsive search ad. Created PAUSED; Google Search only; Maximize conversions (optional target CPA) or Maximize clicks

add_ad_group

Add one ad group with keywords, optional negatives, optional final URL suffix and one RSA to an existing campaign

create_responsive_search_ad

Add a new RSA to an ad group (existing ads untouched)

add_keywords

Add positive keywords to an ad group

add_negative_keywords

Add negatives at campaign or ad-group level

add_sitelinks / add_callouts / add_structured_snippet

Create the assets and attach them to a campaign; add_sitelinks can attach to one ad group instead (ad_group_id)

upload_image_asset

Put one image into the asset library (no link): image_url (https, public hosts, redirects refused, 5 MB) or image_base64 for small files; returns the asset resource name to link with add_image_assets

set_campaign_conversion_goals

Campaign-specific conversion goals: toggle biddable per (category, origin) pair — e.g. bid only on PURCHASE/WEBSITE by setting it true and every other account goal false; one atomic request

set_campaign_custom_conversion_goal

Point a campaign at one custom conversion goal by id (action-level goals: the campaign bids on exactly the listed actions, primary/secondary flags ignored), or "" to return to account-default goals

create_custom_conversion_goal

Create an ENABLED custom conversion goal from a list of conversion action ids

set_conversion_action_primary

Flip a conversion action between primary (biddable under account-default / category goals) and secondary (observation only); custom goals are unaffected

add_image_assets

Upload PNG/JPEG files and attach them to a Search campaign (AD_IMAGE) — or to one ad group via ad_group_id (up to 20 images per campaign and per ad group); validates 1:1 / 1.91:1 shape, minimum size and 5 MB cap

add_business_assets

Attach a business name (TEXT asset, BUSINESS_NAME) and/or business logo (BUSINESS_LOGO, square >=128x128, uploaded or re-linked by asset resource name) to a Search campaign

set_status

ENABLED / PAUSED on a campaign, ad group, ad or keyword

set_campaign_daily_budget

Change a campaign's non-shared daily budget

set_campaign_target_cpa

Set tCPA on a Maximize-conversions campaign

create_portfolio_bidding_strategy

Create a portfolio (shared) Maximize Conversions bid strategy, optionally with a target CPA, and attach campaigns to it in the same atomic request — pools Smart Bidding's learning across campaigns

set_campaign_bidding_strategy

Attach campaigns to an existing portfolio bid strategy, or (no bidding_strategy_id) detach them back to campaign-level Maximize Conversions without a target

set_bidding_strategy_target_cpa

Change (or remove with 0) the target CPA of a portfolio strategy — Maximize Conversions or legacy Target CPA

create_shared_budget

Create an explicitly shared daily budget and move campaigns onto it atomically, so spend flows between them; old individual budgets are left unused

set_shared_budget_amount

Change the daily amount of an explicitly shared budget (set_campaign_daily_budget refuses those)

set_campaign_cpc_ceiling

Set (or remove with 0) the max-CPC ceiling on a Maximize-Clicks campaign (target_spend.cpc_bid_ceiling_micros)

set_final_urls

Update final URLs (ads, assets) and final URL suffixes (ad groups, ads) in one atomic mutate — e.g. move UTMs into the final URL itself

update_responsive_search_ad

Edit an existing RSA in place (same ad id and history; re-review, asset labels reset): replace headlines/descriptions, paths, final URL. Any argument left out is unchanged

set_campaign_final_url_suffix

Campaign-level final URL suffix (ValueTrack, e.g. utm_term={keyword}&kw_match={matchtype}&device={device}), "" clears; ad-group/ad suffixes override it

set_device_bid_modifiers

Campaign-level device bid adjustments in percent; −100 excludes a device (mobile-only = desktop −100, tablet −100). Call it after create_search_campaign with the returned campaign id

set_location_bid_modifiers

Campaign-level location bid adjustments in percent (−90 to +900) on locations the campaign already targets, keyed by geo target constant id ({"2840": 30, "2344": -30}); 0 removes one. Honoured by Maximize clicks / Manual CPC, ignored by Smart Bidding. Never changes targeting

set_campaign_targeting_settings

Change an existing campaign's location option (PRESENCE vs PRESENCE_OR_INTEREST, for targeting and for exclusions) and networks (Search Partners, Display expansion). Arguments left out are unchanged; returns the previous values. Google Search itself is never switched off

set_campaign_locations

Edit an existing campaign's location targeting atomically: add_geo_ids (target), remove_geo_ids (stop targeting), exclude_geo_ids (negative location), unexclude_geo_ids. A targeted geo can be excluded only when it is also removed; geos already in the requested state are skipped and reported

add_campaign_audiences

Attach in-market / affinity segments (user_interest ids) to a Search campaign in OBSERVATION mode: sets the AUDIENCE targeting dimension to bid-only (never narrows who sees the ads) and creates one campaign criterion per segment, optionally with a bid adjustment (−90 to +900). Already-present segments are skipped

remove_entity

Permanently remove a campaign, ad group, ad, keyword, campaign criterion (e.g. a campaign negative) or an asset link (campaign/ad-group asset) — one resource name or a list of the same kind, atomically — only under a campaign carrying the managed label

keyword_ideas

Keyword Planner ideas with monthly volume and bid ranges (read-only, 1 request/second)

audit_log_tail

Last N audit-log entries

create_experiment

SEARCH_CUSTOM A/B experiment on an existing Search campaign: experiment shell plus control/treatment arms with a traffic split; returns the treatment's draft campaign to edit with the other tools. Dry run validates the shell only — arms need the real experiment, so they are created on confirm

schedule_experiment

Start a SETUP experiment serving (materializes the treatment draft; asynchronous on Google's side)

end_experiment

End a running experiment; the base campaign resumes full traffic

promote_experiment

Apply the treatment to the base campaign — the winner becomes the live campaign (asynchronous)

Customer ids may contain dashes. Use the read-only MCP first to look up campaign and ad-group ids and resource names.

Prerequisites

You need the same three things the official read-only server needs — this package creates none of them:

  1. A Google Ads API developer token (from a manager account's API Center).

  2. An OAuth client in a Google Cloud project, and an Ads ADC file produced by gcloud auth application-default login --scopes=https://www.googleapis.com/auth/adwords (an authorized_user JSON with client id, client secret and refresh token).

  3. Edit access on the Google Ads accounts you intend to change. Read-only users can query but every mutate is rejected.

Keep the token and the ADC file out of any repository.

Install

pipx install "git+https://github.com/Luxand/google-ads-write-mcp@v0.5.2"
google-ads-write-mcp --check        # shows which credential files it found; no secrets printed
google-ads-write-mcp --list-tools

The install is pinned to a tag, so pipx upgrade will not move it; upgrade with pipx install --force "git+https://github.com/Luxand/google-ads-write-mcp@<new tag>".

Configuration

Everything is optional if the defaults fit:

Variable

Meaning

Default

GOOGLE_ADS_DEVELOPER_TOKEN

the token itself

—

GOOGLE_ADS_DEVELOPER_TOKEN_FILE

file holding the token

~/.config/google-ads-mcp/developer-token

GOOGLE_APPLICATION_CREDENTIALS

Ads ADC JSON

~/.config/google-ads-mcp/gcloud/application_default_credentials.json

GOOGLE_ADS_LOGIN_CUSTOMER_ID

manager (MCC) id when the target account sits under one; GOOGLE_ADS_MCP_LOGIN_CUSTOMER_ID is accepted as an alias

none

GOOGLE_ADS_WRITE_AUDIT_LOG

audit log path

~/.local/share/google-ads-write-mcp/audit.jsonl

GOOGLE_ADS_WRITE_MANAGED_LABEL

Google Ads label that marks campaigns remove_entity may touch; created in the account on first use

claude-managed

A login customer id applies to the whole client, so register one server entry per manager context you need.

Register in Claude Code

# direct accounts
claude mcp add --scope user google-ads-write -- "$(command -v google-ads-write-mcp)"

# accounts under a manager
claude mcp add --scope user google-ads-write-luxand \
  --env GOOGLE_ADS_LOGIN_CUSTOMER_ID=1234567890 \
  -- "$(command -v google-ads-write-mcp)"

Then in Claude Code, /mcp should list the server and its tools. A safe first call is audit_log_tail, then any tool without confirm (a dry run).

Scripted use

--call TOOL ARGS.json runs one tool from a JSON file through the same code path, same dry-run default and same audit log:

google-ads-write-mcp --call create_search_campaign /abs/path/campaign.json

Useful for pushing a large campaign generated from a spreadsheet, where passing hundreds of keywords as chat tool arguments is impractical. Add "confirm": true to the JSON to apply.

Things the API taught us

  • Search campaigns accept image assets only as AD_IMAGE; MARKETING_IMAGE / SQUARE_MARKETING_IMAGE are Performance Max and Display field types and are rejected with FIELD_TYPE_INCOMPATIBLE_WITH_CAMPAIGN_TYPE.

  • contains_eu_political_advertising is required on campaign creation in current API versions; this server sets it to "does not contain".

  • Geo and language criteria are plain resource names (geoTargetConstants/2840, languageConstants/1000).

  • GenerateKeywordIdeas is limited to 1 request per second per customer id and returns RESOURCE_EXHAUSTED above that; keyword_ideas spaces and retries.

  • A mutate request may hold at most 10,000 operations; a 20-ad-group Search campaign with ~250 keywords is about 370.

  • Device criteria (desktop 30000, mobile 30001, tablet 30002) exist implicitly on every campaign; set a bid modifier by updating campaignCriteria/<campaign>~<id>. Name bid_modifier in the update mask explicitly: a field mask built by comparing against defaults drops a 0.0 (−100%) value and the request succeeds while changing nothing.

  • Setting a campaign back to campaign-level Maximize Conversions means assigning an empty MaximizeConversions message to the campaign_bidding_strategy oneof and naming maximize_conversions in the update mask explicitly; a generated mask sees no set fields and sends nothing.

  • Custom conversion goals live on conversionGoalCampaignConfigs/<campaign>: goal_config_level = CAMPAIGN plus the goal resource name, or CUSTOMER to fall back to account defaults. Toggling a category goal (campaignConversionGoals/<campaign>~<category>~<origin>) also flips the campaign to campaign-specific goals, but categories are shared across products (e.g. SUBMIT_LEAD_FORM), so per-product isolation needs a custom goal. primary_for_goal = false must be named in the update mask explicitly.

License

MIT.

Available Tools

22 tools
add_ad_groupA

Add one ad group - with its keywords, optional ad-group negatives, optional final URL suffix and one responsive search ad - to an EXISTING campaign, atomically. Same argument shapes as create_search_campaign's ad_groups entries. Dry run unless confirm=true.

Inputs: name (required), keywords (required), final_url (required), headlines (required), +9 more. Returns: result

add_business_assetsA

Attach a business name and/or business logo to a Search campaign.

business_name (<=25 chars, Google's limit) is created as a TEXT asset and linked as BUSINESS_NAME. For the logo pass logo_path (absolute PNG/JPEG, square within 2%,

=128x128, <=5 MB; 1200x1200 recommended) to upload a new asset, or logo_asset_resource_name to re-link an image asset the account already holds; either is linked as BUSINESS_LOGO. At least one input is required. Dry run unless confirm=true.

Inputs: campaign_id (required), customer_id (required), confirm, logo_path, +2 more. Returns: result

add_calloutsA

Create callout assets (each <=25 chars) and attach them to a campaign. Dry run unless confirm=true.

Inputs: callouts (required), campaign_id (required), customer_id (required), confirm. Returns: result

add_image_assetsA

Upload image files as image assets and attach them to a Search campaign as AD_IMAGE.

images: [{"path": "/absolute/file.png", "field_type": "SQUARE_MARKETING_IMAGE"|"MARKETING_IMAGE", "name": optional asset name}, ...] field_type here declares the SHAPE (1:1 or 1.91:1) and drives local validation; the link itself is always AD_IMAGE, which is the only image field type Search campaigns accept (MARKETING_IMAGE / SQUARE_MARKETING_IMAGE are Performance Max and Display field types). or, to attach an image already in the account: {"asset_resource_name": "customers/../assets/..", "field_type": ...}. PNG or JPEG, <=5 MB, 1:1 >=300x300 or 1.91:1 >=600x314; the file's aspect ratio must match the field_type. Google rejects an image identical to an existing asset (DUPLICATE_ASSET) - pass its asset_resource_name instead. Dry run unless confirm=true.

Inputs: images (required), campaign_id (required), customer_id (required), confirm. Returns: result

add_keywordsA

Add positive keywords to an ad group. keywords: [{"text": "photo to pdf", "match_type": "PHRASE|EXACT|BROAD"}]. Dry run unless confirm=true.

Inputs: keywords (required), ad_group_id (required), customer_id (required), status, +1 more. Returns: result

add_negative_keywordsA

Add NEGATIVE keywords at campaign level (campaign_id) or ad-group level (ad_group_id). match_type: PHRASE (default) | EXACT | BROAD. Dry run unless confirm=true.

Inputs: keywords (required), customer_id (required), confirm, match_type, +2 more. Returns: result

add_structured_snippetA

Create one structured-snippet asset (header e.g. "Types", "Services"; 3-10 values, each <=25 chars) and attach it to a campaign. Dry run unless confirm=true.

Inputs: header (required), values (required), campaign_id (required), customer_id (required), +1 more. Returns: result

audit_log_tailA

Return the last n entries of this server's local audit log (dry runs and applied changes).

Inputs: n. Returns: result

create_responsive_search_adA

Create a NEW responsive search ad in an ad group (existing ads are untouched).

headlines: 3-15 items, each <=30 chars; descriptions: 2-4 items, each <=90 chars. An item may be a string or {"text": "...", "pin": "HEADLINE_1|HEADLINE_2|HEADLINE_3|DESCRIPTION_1|DESCRIPTION_2"}. status: ENABLED or PAUSED. Dry run unless confirm=true.

Inputs: final_url (required), headlines (required), ad_group_id (required), customer_id (required), +5 more. Returns: result

create_search_campaignA

Create a complete Search campaign in ONE atomic mutate: budget, campaign, location and language criteria, campaign negatives, and every ad group with its keywords and one RSA.

The campaign gets the managed label (default "claude-managed"; created in the account on first use) so remove_entity can later act on it. It is created PAUSED; enable it with set_status. locations: geo target constant ids (United States = 2840); location targeting is created presence-only (positive and negative geo_target_type = PRESENCE). languages: language constant ids (English = 1000); an empty list means "all languages" (no language criterion is created). bidding: MAXIMIZE_CONVERSIONS (optional target_cpa) or MAXIMIZE_CLICKS (optional cpc_bid_ceiling in account currency; omitted = uncapped). Headlines/descriptions accept plain strings or {"text", "pin": "H1".."H3" | "D1" | "D2"}. Network: Google Search only (no partners, no Display). ad_groups: [{"name", "final_url", "keywords": [{"text","match_type"}], "headlines": [...], "descriptions": [...], "path1", "path2", "status", "final_url_suffix", "negative_keywords": [...]}, ...] negative_keywords: [{"text", "match_type"}] at campaign level. Device bid adjustments (e.g. mobile-only) are a follow-up call to set_device_bid_modifiers with the returned campaign id: the device criteria only exist once the campaign does. Extensions (sitelinks, callouts, snippets, images) are separate tools - call them with the returned campaign id. Dry run unless confirm=true.

Inputs: name (required), ad_groups (required), languages (required), locations (required), +7 more. Returns: result

keyword_ideasA

Keyword Planner ideas with US-style monthly search volume and bid ranges. READ-ONLY: creates nothing, so there is no confirm flag. Costs one API operation per call, rate-limited to 1/second.

seeds: up to 20 seed keywords; url: a landing page to seed from (either or both). geo_target_ids: geo target constant ids (default [2840] = United States); language_id: language constant id (default 1000 = English). Google Search network only (no partners). Returns ideas sorted by avg_monthly_searches desc, up to limit. Volumes are rounded 12-month averages and close variants share one aggregated number.

Inputs: customer_id (required), url, limit, seeds, +3 more. Returns: result

remove_entityA

PERMANENTLY remove a campaign, ad group, ad (adGroupAds/...), keyword (adGroupCriteria/...), campaign criterion such as a campaign negative keyword (campaignCriteria/...), or an asset LINK (campaignAssets/..., adGroupAssets/... - the asset itself stays in the account's asset library).

resource_name may be one resource name or a list of names of the SAME kind; a list is removed in one atomic mutate. Refuses unless every entity sits under a campaign carrying the managed label (default "claude-managed") - i.e. one created by this server. Google Ads has no restore for removed entities; historical data stays in reports but the entity cannot be re-enabled. Dry run unless confirm=true.

Inputs: customer_id (required), resource_name (required), confirm. Returns: result

set_campaign_cpc_ceilingA

Set the max CPC bid ceiling (account currency, e.g. 5.0) of a campaign that uses Maximize Clicks (Campaign.target_spend). Pass 0 to remove the ceiling. Dry run unless confirm=true.

Inputs: campaign_id (required), customer_id (required), cpc_bid_ceiling (required), confirm. Returns: result

set_campaign_daily_budgetA

Set a campaign's daily budget (account currency). Refuses if the budget is shared with other campaigns. Dry run unless confirm=true.

Inputs: campaign_id (required), customer_id (required), daily_budget (required), confirm. Returns: result

set_campaign_final_url_suffixA

Set the CAMPAIGN-level final URL suffix, or clear it with "". Google appends the suffix to every final URL in the campaign at click time and substitutes ValueTrack tokens, e.g. "utm_term={keyword}&kw_match={matchtype}&device={device}". An ad-group or ad suffix overrides the campaign's, so keep the suffix at one level. Ads, their text and history are untouched. Dry run unless confirm=true.

Inputs: campaign_id (required), customer_id (required), final_url_suffix (required), confirm. Returns: result

set_campaign_target_cpaA

Set the target CPA (in account currency, e.g. 9.5) of a campaign that uses Maximize Conversions. Dry run unless confirm=true.

Inputs: target_cpa (required), campaign_id (required), customer_id (required), confirm. Returns: result

set_device_bid_modifiersA

Set campaign-level device bid adjustments in percent (-100 to +900). -100 excludes the device: a mobile-only Search campaign is desktop=-100, tablet=-100. Smart Bidding strategies honour only the -100 case; other values are advisory under Maximize conversions / tCPA. Leave a device None to keep it unchanged. Dry run unless confirm=true.

Inputs: campaign_id (required), customer_id (required), mobile, tablet, +2 more. Returns: result

set_final_urlsA

Update final URLs and/or final URL suffixes in one atomic mutate.

targets: [{"resource_name": <ad (adGroupAds/..~..) | ad group (adGroups/..) | asset (assets/..)>, "final_url": "https://..." (ads and assets), "final_url_suffix": "utm_..." or "" to clear (ad groups and ads)}, ...] Ads keep their text and history: Google allows changing an ad's final URLs in place. Typical use: move UTMs from an ad-group suffix into the final URL itself, or retag a campaign. Dry run unless confirm=true.

Inputs: targets (required), customer_id (required), confirm. Returns: result

set_location_bid_modifiersA

Set campaign-level LOCATION bid adjustments in percent (-90 to +900) on locations the campaign already targets. modifiers maps a geo target constant id (2840 = US, 2344 = Hong Kong; also accepts "geoTargetConstants/2840") to the adjustment: {"2840": 30, "2344": -30}. 0 removes an adjustment. Honoured by Manual CPC and Maximize clicks; Smart Bidding (tCPA / Maximize conversions) ignores location adjustments. Targeting is never changed: a geo the campaign does not target is rejected. One atomic mutate; dry run unless confirm=true.

Inputs: modifiers (required), campaign_id (required), customer_id (required), confirm. Returns: result

set_statusA

Set ENABLED or PAUSED on a campaign, ad group, ad (adGroupAds/......) or keyword (adGroupCriteria/......). resource_name must be the full resource name from the read MCP, e.g. customers/123/campaigns/456. Dry run unless confirm=true. (REMOVED is intentionally not supported.)

Inputs: status (required), customer_id (required), resource_name (required), confirm. Returns: result

update_responsive_search_adA

Edit an EXISTING responsive search ad in place (AdService update): the ad keeps its id and reporting history, goes back through policy review, and its asset performance labels / Ad Strength reset. Every argument left None is unchanged. headlines / descriptions, when given, REPLACE the whole list (3-15 headlines <=30 chars, 2-4 descriptions <=90 chars; items are strings or {"text", "pin": "HEADLINE_1|HEADLINE_2|HEADLINE_3|DESCRIPTION_1|DESCRIPTION_2"}). path1 / path2: "" clears. ad_id: the numeric ad id or an adGroupAds/~ resource name. Dry run unless confirm=true.

Inputs: ad_id (required), customer_id (required), path1, path2, +4 more. Returns: result

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.2/5.0

    Scored across 22 tools

    Disambiguation5/5

    Each tool clearly targets a distinct Google Ads entity or lifecycle action: campaign creation, ad group addition, keyword/negative keyword management, RSA create/update, asset extensions, bid/budget settings, and removal. The only near-overlap, set_campaign_final_url_suffix versus set_final_urls, is clearly separated by campaign-level vs ad/ad-group/asset-level scope.

    Naming Consistency4/5

    The majority of tools follow a clear snake_case verb_noun pattern using create_, add_, set_, update_, and remove_. The exceptions are keyword_ideas and audit_log_tail, which are noun-phrase read/utility tools, and the create_/ add_ distinction is subtle, so the set is mostly consistent but not perfectly uniform.

    Tool Count3/5

    At 22 tools, the server sits in the heavy range and feels larger than the typical well-scoped toolset. However, the count is understandable given the breadth of Google Ads write operations, and most tools cover distinct, non-redundant actions.

    Completeness4/5

    The surface covers the core Search campaign lifecycle: create campaign/ad group/RSA, manage keywords and negatives, attach sitelinks/callouts/snippets/image/business assets, update budget/bids/status/final URLs, and remove entities. Minor gaps remain, such as keyword bid updates and in-place extension edits, but they can be worked around with remove/re-add.

    Maintenance

    ActivitySlowing
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      C
      maintenance
      MCP server that exposes write operations on Google Ads, enabling management of campaigns, ad groups, keywords, RSA ads, sitelinks, images, Customer Match audiences, and recommendations.
      MIT
    • A
      license
      A
      quality
      C
      maintenance
      An MCP server that extends the official Google Ads MCP with safe, confirmed campaign administration across manager accounts, featuring immutable mutation plans, multi-step approvals, and post-write verification.
      8
      Apache 2.0
    • A
      license
      Not graded
      quality
      D
      maintenance
      MCP server for managing Google Ads campaigns through the official Google Ads API, covering accounts, campaigns, budgets, keywords, search terms, and keyword ideas. It provides tools for both reading and mutating live ads data, such as pausing campaigns, updating budgets, and adding keywords.
      MIT
    • A
      license
      A
      quality
      C
      maintenance
      An MCP server that provides read and write access to Google Ads, allowing natural language management of campaigns, budgets, ad groups, bids, and keywords, with dry-run validation for safety.
      18
      MIT