adsmith
README.md
<h1 align="center">adsmith</h1>
<p align="center">
<strong>Let Claude actually run your Google Ads — with a diff, a dry-run, and an undo before anything touches your budget.</strong>
</p>
<p align="center">
<a href="https://github.com/sujayrittikar/adsmith/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/sujayrittikar/adsmith/actions/workflows/ci.yml/badge.svg"></a>
<a href="https://pypi.org/project/adsmith/"><img alt="PyPI" src="https://img.shields.io/pypi/v/adsmith"></a>
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Google Ads API" src="https://img.shields.io/badge/Google%20Ads%20API-v25-4285F4">
</p>
A **Google Ads MCP server** that does more than read. It finds your wasted spend,
your budget-capped winners, and your weak ad copy — then proposes the fix as a
reviewable diff that you approve before a single cent moves.
> Google's [official Google Ads MCP server](https://github.com/googleads/google-ads-mcp)
> is read-only: three tools, no mutations. adsmith is the other half — the part
> that changes things, wrapped in the safety machinery that makes letting an LLM
> near an ad budget a reasonable idea.
---
## Try it in 30 seconds — no Google account needed
```bash
uvx adsmith --demo
```
That's it. No OAuth, no developer token, no waiting on Google's approval queue.
`--demo` boots a synthetic ad account with realistically messy data, and **every
tool works against it**, including proposing, applying, and rolling back changes.
Wire it into Claude Code:
```bash
claude mcp add adsmith -- uvx adsmith --demo
```
Then ask: *"where am I wasting money?"*
<!-- Record with: ./scripts/record_demo.sh (needs vhs + ttyd + ffmpeg)
Then swap the code block below for:  -->
```
> where am I wasting money?
Pulling the search-terms report for Northwind Outdoor Gear...
$6,274.92 of your $14,525 spend over the last 30 days went to search terms
that produced zero conversions — about 43%. The pattern is clear:
| search term | 30-day cost | campaign |
|--------------------------------|-------------|------------------------|
| free hiking boots | $261.98 | Search - Hiking Boots |
| hiking boots repair near me | $238.70 | Search - Hiking Boots |
| northwind outdoor gear careers | $231.27 | Brand - Northwind |
| how to clean hiking boots | $215.41 | Search - Hiking Boots |
| used camping gear cheap | $191.82 | Search - Generic Outdoor |
Three intent groups worth blocking: job seekers, repair/rental, and
free-seekers. Want me to propose negatives?
> yes
Change-set cs_1a5956d1 (pending) on customer 1234567890
Rationale: Non-commercial intent
1. Negative keyword on Search - Generic Outdoor: + keyword=jobs
2. Negative keyword on Search - Generic Outdoor: + keyword=rental
3. Negative keyword on Search - Generic Outdoor: + keyword=diy
4. Negative keyword on Search - Generic Outdoor: + keyword=wikipedia
5. Negative keyword on Search - Generic Outdoor: + keyword=donation
Estimated daily spend decrease: 19.92
Estimated 30-day impact: 597.60
Expires: 2026-08-07T18:06:21+00:00
To apply: apply_changeset(changeset_id='cs_1a5956d1', confirm=True)
Nothing has been applied. Note I left "careers" alone — that spend is on your
brand campaign, which your guardrails protect.
```
Nothing was applied. That's the point.
---
## Safety, up front
The honest first reaction to "AI edits my ad account" is *no*. So here is exactly
what stops it going wrong.
**Every mutation takes the same path.** There is no other route to the API:
```
propose_* → guardrails → validate_only dry-run → apply_changeset(confirm=True)
↓ ↓
BLOCK is final Google validates without committing
```
**Guardrails live in a file on disk, outside the model's reach.** No tool can
relax one. A `BLOCK` is terminal — the assistant can't argue past it, because the
check is Python reading YAML, not a suggestion in a prompt.
```yaml
budgets:
max_increase_pct_per_day: 20 # no 10x raises from a stray decimal
max_decrease_pct_per_day: 30
account_daily_spend_ceiling: null # set this before automating anything
bids:
max_target_cpa_change_pct: 15 # big swings reset Smart Bidding's learning
pausing:
never_pause_if_conversions_last_30d: 5 # conversions lag; "dead" often isn't
max_keywords_paused_per_changeset: 50
allow_pausing_campaigns: false
protected:
campaign_ids: []
campaign_name_patterns: ["*Brand*"] # brand campaigns are cheap and high-ROAS;
# "optimizing" them is nearly always a loss
```
Ask for something out of bounds and you get this, not a workaround:
```
Guardrails:
- [BLOCK] budgets.max_increase_pct_per_day (change 1): Raising the budget for
Search - Hiking Boots from 80.00 to 800.00 is +900.0%, above the 20% daily cap.
BLOCKED by guardrails — this change-set cannot be applied. The [BLOCK] entries
above are final.
These limits live in ~/.config/adsmith/guardrails.yaml. I cannot override them;
editing that file is the only way to change them.
```
**The rest of the safety model:**
- **Approval is explicit.** `apply_changeset` without `confirm=True` returns the
diff and changes nothing.
- **Dry run first.** Every apply is validated through the API's `validate_only`
mode before it commits.
- **Undo.** Change-sets carry pre-built inverse operations. Where something
genuinely can't be reversed (creating an ad), it says so plainly instead of
implying a clean undo.
- **Proposals go stale.** A change-set built on last week's numbers expires
rather than applying against an account that has moved.
- **Guardrails re-run at apply time**, not just at propose time — so tightening
the policy invalidates a proposal that was already sitting in the queue.
- **Everything is logged.** `audit_history` answers "what changed last Tuesday?"
with every proposal, verdict, dry-run, application, and rollback.
Full detail in [SECURITY.md](SECURITY.md).
---
## Playbooks
Five slash commands, each a written analysis procedure rather than a black box.
They're [plain prose you can read and edit](src/adsmith/prompts/__init__.py) —
including the statistical guardrails that stop an eager assistant acting on noise.
| Command | What it does |
|---|---|
| `/wasted-spend` | Mines search terms, groups them by *intent* (job seekers, DIY, free-seekers, repair), proposes negatives with a projected saving |
| `/budget-check` | Finds campaigns losing impression share to budget *while hitting their CPA target*, proposes a zero-sum reallocation |
| `/bid-tuning` | Compares actual CPA/ROAS to targets, proposes bounded steps — and refuses to act on fewer than 30 conversions |
| `/ad-audit` | Finds poor-strength RSAs, too-few headlines, over-pinned assets; proposes replacements that vary the *angle*, not the wording |
| `/weekly-report` | Digest with anomaly detection — and it checks whether a conversion cliff is a tracking break before calling it a performance drop |
---
## Tools
<details>
<summary><strong>Reporting (9)</strong> — curated reports, not a raw GAQL passthrough</summary>
| Tool | Returns |
|---|---|
| `list_accounts` | Every reachable account with currency and timezone |
| `account_overview` | Spend, conversions, CPA, ROAS vs. the preceding period |
| `campaign_performance` | Per-campaign metrics + impression share + budget |
| `ad_group_performance` | Per-ad-group metrics |
| `keyword_performance` | Per-keyword metrics with quality score |
| `search_terms` | What people typed, with wasted spend totalled |
| `ad_performance` | Ads plus a precomputed `weak_ads` diagnosis |
| `budget_pacing` | MTD spend, month-end projection, budget-capped campaigns |
| `run_gaql` | Read-only escape hatch — SELECT only |
Reports pre-aggregate totals and cap rows by default. When a report truncates it
says so, and the totals still cover every row — a capped report never understates
account spend.
</details>
<details>
<summary><strong>Mutations (11)</strong> — all routed through change-sets</summary>
| Tool | Notes |
|---|---|
| `propose_negative_keywords` | Defaults to PHRASE; BROAD negatives block converting traffic too often |
| `propose_budget_change` | Takes a whole reallocation map as one reviewable change-set |
| `propose_bid_adjustment` | tCPA/tROAS only; refuses to switch bidding strategy |
| `propose_pause` | Keywords, ads, campaigns |
| `propose_rsa` | Validates Google's 30/90-character limits before proposing |
| `preview_changeset` | Diff + `validate_only` dry run |
| `apply_changeset` | Requires `confirm=True` |
| `rollback_changeset` | Replays stored inverses |
| `list_changesets` | Recent proposals and their status |
| `audit_history` | The full record |
| `show_guardrails` | Active policy and where it's loaded from |
</details>
---
## Running it unattended
`adsmith scan` does the analysis headlessly — point cron at it and the work
happens while you sleep.
```bash
adsmith scan --demo # digest of everything worth your attention
adsmith scan --propose # ...and queue change-sets for the clear-cut fixes
```
```
9 finding(s), roughly 9,157.94 per 30 days at stake.
1. [WARNING] Search - Generic Outdoor: 2,384.42 on non-converting search terms
85 search term(s) spent money over 30 days with no conversions.
Queued: cs_ace79e8b (pending your approval)
3. [WARNING] Search - Camping Gear: CPA is +105% against target
Actual CPA 61.41 vs target 30.00 over 61 conversions.
5. [WARNING] Search - Hiking Boots is capped and beating its target
Losing 38% of impressions to budget on 80.00/day, at a CPA of 38.00
against a 45.00 target.
```
**It never applies anything** — `--propose` queues change-sets in `pending`, and
guardrails apply in full with nobody watching. The scan is deterministic Python,
not an LLM: it costs nothing per run, gives identical output for identical input,
and can't hallucinate a campaign ID. The judgement calls stay in the playbooks
where you're present to read them.
Exit codes are cron-shaped (`0` clean, `1` error, `2` findings), so
`adsmith scan || notify-me` just works. Full setup, including systemd timers, in
[docs/SCHEDULING.md](docs/SCHEDULING.md).
## Read-only mode
```bash
adsmith --read-only
```
Unregisters every mutation tool, so the server *physically cannot* write —
removal, not refusal. A good way to spend a first week on a real account.
## Connecting a real account
`--demo` needs nothing. A real account needs three things from Google, and the
developer token is the slow one — Google's review queue runs days to weeks, and
a fresh token only works against *test* accounts until Basic access is approved.
**Start the token application on day one, then keep using `--demo` while you
wait.** Full walkthrough in [docs/SETUP.md](docs/SETUP.md).
```bash
pip install 'adsmith[live]'
adsmith auth # one-time OAuth; writes ~/.config/adsmith/credentials.json (0600)
adsmith init-config # writes ~/.config/adsmith/guardrails.yaml — edit it
claude mcp add adsmith -- adsmith
```
Set `account_daily_spend_ceiling` in your guardrails before you do anything else.
---
## How this differs from Google's official server
Both are open source and both are Python. They solve different halves.
| | [googleads/google-ads-mcp](https://github.com/googleads/google-ads-mcp) | **adsmith** |
|---|---|---|
| Reads | Raw GAQL passthrough | 9 curated reports + a GAQL escape hatch |
| Writes | — | 5 proposal tools behind change-set review |
| Guardrails | — | Policy file the model can't override |
| Undo | — | Inverse operations + audit log |
| Playbooks | — | 5 slash commands |
| Unattended scanning | — | `adsmith scan` (cron-shaped exit codes) |
| Read-only mode | Always read-only | `--read-only` opt-in |
| Try without a dev token | — | `uvx adsmith --demo` |
| Telemetry | Usage headers on API calls | None |
| Maintained by | Google | This repo |
Use theirs if you want a thin, official, read-only wrapper. Use adsmith if you
want the thing to actually improve the account.
---
## Contributing
You don't need a Google Ads account to contribute — the whole test suite runs
against the demo backend. See [CONTRIBUTING.md](CONTRIBUTING.md).
The one rule: **nothing reaches the API except through a change-set.** A tool
that calls `mutate` directly won't be merged, however convenient.
## Status
Alpha, and honest about it. 102 tests, CI on 3.12/3.13.
The safety layer is thoroughly covered. The live Google API path is tested
against real protobuf types — message construction, enums, field masks, error
translation — with the network stubbed, but it has never run against Google's
actual servers. That's the part most likely to surprise you.
If you connect a real account: start with `--read-only` for a week, then tight
guardrails and one small change. `docs/SETUP.md` §7 has the round trip worth
doing first.
## Licence
MIT.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues