
Every Google Ads account I touch has the same hidden tax buried in the search-term report. Broad match and Performance Max have pulled in queries the account was never meant to bid on, the negative list hasn't been refreshed in months, and there are real high-intent terms converting through broad match that should be promoted to exact. The data is right there. Nobody has the patience to scroll through 1,200 rows of it on a Tuesday.
So I built a Claude Code skill for Google Ads keyword discovery. The starter version is below. You can paste it onto your machine and run it against a real export today. The version I actually use across accounts is more involved, and I'll get to where that lives at the end.
What a Claude Code skill is
A skill is a directory in ~/.claude/skills/ with a SKILL.md file and any helper scripts. When you trigger it, Claude reads the SKILL.md, follows the instructions inside, and runs the scripts. There's no framework and no SDK. It's a folder Claude knows how to read.
Why intent clustering beats sorting by spend
The default move in Google Ads is to sort the search-term report by cost descending and start adding negatives from the top. That misses the actual structure of the report. A $40 spend on a competitor brand term might be more urgent than a $200 spend on a category term that's converting. A high-intent informational query buried at row 380 might be worth promoting before you cut anything.
Clustering by intent flips the order. You look at the report by what the searcher was trying to do, not by what it cost. Five clusters cover most of what shows up in any account:
- Brand — your own brand and product names. Should match against a brand campaign. If brand terms are leaking into other campaigns, the structure is wrong.
- Category — the generic product or service. Usually the real money. Where you want exact match.
- Competitor — other brands in your space. Sometimes a deliberate strategy, often accidental.
- Informational — "how to", "what is", "best", "vs". Sometimes converts. Worth surfacing separately.
- Waste — irrelevant, off-topic, location mismatches, free-stuff searchers. The negative list.
Once the rows are clustered, three actions fall out of the data: a list of negative-keyword candidates, a list of new exact-match candidates that are already converting via broad, and a brand-protection alert when competitor terms are showing up against your brand.
The starter skill
Two files. Copy them as-is.
First file is the skill instructions at ~/.claude/skills/google-ads-keyword-discovery/SKILL.md:
---
name: google-ads-keyword-discovery
description: Cluster a Google Ads search-term report by intent (brand, category, competitor, informational, waste) and surface negative-keyword candidates, new exact-match candidates, and brand-protection alerts. Trigger on "discover keywords in [csv]", "cluster my search terms", "find negatives in [path]".
---
# google-ads-keyword-discovery
You read a Google Ads search-term report and produce a short markdown report with three sections: negatives to add, exact-match candidates to promote, and brand-protection alerts.
## Inputs
The user gives you:
1. A path to a search-term report CSV exported from Google Ads (Campaigns → Search terms → Download). Last 30-90 days is typical.
2. The brand name and 1-2 competitor brand names. Ask if not provided.
The CSV should include at minimum: Search term, Campaign, Cost, Conversions, Clicks, Impressions, Match type.
## Run the cluster
From this skill's directory, run:
python3 scripts/cluster.py
The script prints a markdown report to stdout. Return it to the user as-is. Don't reformat.
## How clustering works
The script classifies each search term into one of five clusters using simple token rules:
- Brand cluster: contains a brand token
- Competitor cluster: contains a competitor token
- Informational cluster: starts with how/what/why/best/vs/review
- Waste cluster: matches a built-in waste list (free, jobs, salary, login, download, pdf, torrent)
- Category cluster: everything else
## After the report
If the user asks "what should I add as negatives?" — recommend everything in the waste cluster plus any competitor terms not deliberately bid on. The exact-match candidates need a human sanity check before being added — Claude shouldn't push them automatically.
Second file is the script at ~/.claude/skills/google-ads-keyword-discovery/scripts/cluster.py:
#!/usr/bin/env python3
"""Cluster a Google Ads search-term report by intent. Stdlib only."""
import argparse
import csv
import re
import sys
from collections import defaultdict
INFO_PREFIXES = ("how ", "what ", "why ", "best ", "vs ", "review")
WASTE_TOKENS = ["free", "jobs", "salary", "login", "download",
"pdf", "torrent", "wikipedia", "lyrics", "meme"]
EXACT_MATCH_MIN_CONV = 1.0
EXACT_MATCH_MIN_CLICKS = 5
def to_float(v):
try: return float(str(v).replace("$", "").replace(",", ""))
except (ValueError, TypeError): return 0.0
def tokenize(brand_list):
return [b.strip().lower() for b in brand_list.split(",") if b.strip()]
def classify(term, brand_toks, comp_toks):
t = term.lower().strip()
if any(b in t for b in brand_toks): return "brand"
if any(c in t for c in comp_toks): return "competitor"
if t.startswith(INFO_PREFIXES): return "informational"
if any(w in t.split() for w in WASTE_TOKENS): return "waste"
return "category"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("path")
ap.add_argument("--brand", required=True)
ap.add_argument("--competitors", default="")
args = ap.parse_args()
brand_toks = tokenize(args.brand)
comp_toks = tokenize(args.competitors)
rows = []
with open(args.path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for r in reader:
term = r.get("Search term", "").strip()
if not term or term.lower().startswith("total"): continue
rows.append({
"term": term,
"campaign": r.get("Campaign", "").strip(),
"cost": to_float(r.get("Cost", 0)),
"conv": to_float(r.get("Conversions", 0)),
"clicks": to_float(r.get("Clicks", 0)),
"match": r.get("Match type", "").strip().lower(),
})
if not rows:
print("No search-term rows found in CSV."); return
clusters = defaultdict(list)
for r in rows:
r["cluster"] = classify(r["term"], brand_toks, comp_toks)
clusters[r["cluster"]].append(r)
total_cost = sum(r["cost"] for r in rows)
total_conv = sum(r["conv"] for r in rows)
print(f"# Google Ads Keyword Discovery\n")
print(f"{len(rows)} search terms | ${total_cost:,.0f} cost | {total_conv:.0f} conversions\n")
print("## Cluster summary\n")
for c in ["brand", "category", "competitor", "informational", "waste"]:
rs = clusters[c]
cost = sum(r["cost"] for r in rs)
conv = sum(r["conv"] for r in rs)
print(f"- **{c}** — {len(rs)} terms, ${cost:,.0f} cost, {conv:.0f} conv")
waste = sorted(clusters["waste"], key=lambda x: -x["cost"])
print(f"\n## Negative-keyword candidates (waste cluster)\n")
for r in waste[:15]:
print(f"- `{r['term']}` — ${r['cost']:.0f} cost, {r['conv']:.0f} conv [{r['campaign']}]")
exact_candidates = [r for r in clusters["category"]
if r["conv"] >= EXACT_MATCH_MIN_CONV
and r["clicks"] >= EXACT_MATCH_MIN_CLICKS
and r["match"] != "exact"]
exact_candidates.sort(key=lambda x: -x["conv"])
print(f"\n## New exact-match candidates (category cluster, converting via broad)\n")
for r in exact_candidates[:15]:
print(f"- `{r['term']}` — {r['conv']:.0f} conv, ${r['cost']:.0f} cost, match `{r['match']}`")
print(f"\n## Brand-protection alerts (competitor terms in brand campaign)\n")
brand_camp_comp = [r for r in clusters["competitor"]
if any(b in r["campaign"].lower() for b in brand_toks)]
if not brand_camp_comp:
print("- None detected.")
for r in brand_camp_comp[:10]:
print(f"- `{r['term']}` in **{r['campaign']}** — ${r['cost']:.0f} cost")
if __name__ == "__main__":
main()
Both files run as written. Stdlib only — no pandas, no pip install.
How to use it
Three steps. First, make the directory and cd in:
mkdir -p ~/.claude/skills/google-ads-keyword-discovery/scripts
cd ~/.claude/skills/google-ads-keyword-discovery
Second, save the two files above as SKILL.md and scripts/cluster.py.
Third, export a search-term report from Google Ads. Go to Campaigns → Search terms → Download → CSV. Use a 30-90 day window. Then from Claude Code, say:
discover keywords in ~/Downloads/search-terms.csv for brand "shelskys" competitors "russ and daughters, zabars"
Claude reads the skill, runs the script, and prints the report. About 15 seconds from "open terminal" to "report on screen", most of which is the CSV export.
Example output
Here's what a report looks like on a real account. Names changed.
# Google Ads Keyword Discovery
847 search terms | $4,210 cost | 38 conversions
## Cluster summary
- **brand** — 62 terms, $380 cost, 18 conv
- **category** — 491 terms, $2,890 cost, 17 conv
- **competitor** — 28 terms, $310 cost, 1 conv
- **informational** — 94 terms, $190 cost, 0 conv
- **waste** — 172 terms, $440 cost, 0 conv
## Negative-keyword candidates (waste cluster)
- `bagel jobs nyc` — $48 cost, 0 conv [Search - Category]
- `free bagels brooklyn` — $36 cost, 0 conv [Performance Max]
- `bagel meme template` — $22 cost, 0 conv [Search - Category]
## New exact-match candidates (category cluster, converting via broad)
- `everything bagel sampler box` — 4 conv, $58 cost, match `broad`
- `lox and bagel gift box brooklyn` — 3 conv, $42 cost, match `broad`
- `kosher bagel delivery nyc` — 2 conv, $31 cost, match `broad`
## Brand-protection alerts (competitor terms in brand campaign)
- `russ and daughters` in **Search - Brand** — $18 cost
Three actions I can take this afternoon. The waste cluster goes into a shared negative list. The three converting broad-match terms get promoted to exact match in a tightly themed ad group. The competitor term leaking into the brand campaign gets added as a negative there and a separate competitor campaign decision goes on the followup list.
Where the starter falls short
The starter is useful. It's also limited on purpose. Here's what it doesn't do.
- One-shot CSV — You export the file manually every time. No live API pull from the Google Ads account, no scheduled runs, no Monday-morning email.
- Single account, single window — One CSV per run. If you manage a book of accounts you do this each time, and there's no way to compare a 30-day window against a 90-day baseline to spot recent drift.
- Hard-coded cluster rules — The waste list and informational prefixes are fixed in the script. Accounts in regulated verticals or niche categories have their own quirks the starter can't learn.
- No negative-keyword push — The starter prints candidates to the terminal. You still copy them into the Google Ads UI by hand. Nothing writes back to the account.
The production version
The full skill is part of the Ad Operator pack on operatorstack.app/packs/ad-operator ($149). It pulls search-term data live from the Google Ads API, runs the same cluster logic but auto-tunes the rules per account based on conversion history, batches across a book of accounts in one command, and pushes approved negatives back to the account so the UI step goes away.
I'm not going to oversell it. If the starter solves your problem, use the starter. The production version exists because the manual CSV loop stops scaling fast when more than a couple of accounts need the same treatment on a regular cadence.
Bonus: API access
If you want to skip the CSV step in the starter, wire a Google Ads API developer token and OAuth credentials. Once you have those, swap the csv.DictReader call in cluster.py for a GAQL query against the search_term_view resource. Same script, same output, no manual export. You can then schedule it via launchd or cron to run weekly. Google's API docs cover the auth flow and the search-term query at developers.google.com/google-ads/api/docs.
Closing
The starter solves the Tuesday-afternoon search-term chore on one account. It took me a couple of hours to build and pays for itself the first time it surfaces a converting broad-match term that nobody noticed.
Build the starter. If you want the version that runs across a book of accounts and pushes negatives back automatically, grab the Ad Operator pack. Or hire Clare Digital to run keyword discovery across your Google Ads accounts.