- The math is the easy part. A rolling 14-day median with a MAD (median absolute deviation) threshold around 3.5 works fine. Z-score doesn’t, because one bad day inflates the spread and hides the next anomaly.
- The dedupe hash decides survival. Key the suppression on
(campaign_id + metric + direction + ISO_week_bucket). Without the week bucket, the agent re-alerts every night the same campaign keeps bleeding, and your team mutes it by day 10. - Classify before you page. A CPA (cost per acquisition) spike with stable conversion rate is an auction story. A CPA spike with collapsed conversion rate and stable impressions is almost always a tracking break, and the Slack message should say that.
- Read-only Google Ads MCP scopes only. The agent recommends. A human executes. Pause, bid change, and budget shift never go through the bot.
- The whole stack is small: one Claude Code agent, the Google Ads MCP server, a DuckDB baseline file, and a Slack incoming webhook. You can wire it in an afternoon.
Questions this article answers:
- What does a nightly Google Ads anomaly agent actually do that a Google Ads script can’t?
- What baseline math should the agent use, and is z-score really wrong?
- How do you stop the agent from spamming Slack every night the same campaign keeps bleeding?
- How does the agent tell a tracking break apart from a real CPA spike?
- What scopes should the Google Ads MCP have, and what should the agent never be allowed to do?
- When is nightly the wrong cadence?
Most “AI anomaly detection” for Google Ads is z-score alerting in a costume. It works for a week. Then the same three campaigns keep bleeding, the agent keeps re-alerting on them every night, and the on-call media buyer mutes the channel. You built an agentic workflow for nightly Google Ads anomaly detection and lost it to alert fatigue inside ten days.
The math isn’t what kills these builds. Two design decisions do: the deduplication key, and the cause classification step that runs before anything reaches Slack. Get those right and the agent stays useful. Get them wrong and you’re back to checking the Google Ads UI by hand at 7 AM.
This is the build. Four components, one cron job, and the two design choices that decide whether the team keeps it on.
What does a nightly Google Ads anomaly agent actually do that a Google Ads script can’t?
A nightly Google Ads anomaly agent is a scheduled program that pulls yesterday’s campaign performance, compares it to a rolling baseline, classifies the likely cause of any anomaly, and posts a triaged alert to Slack with a suggested next step.
A detector tells you CPA is up 40%. A triage system tells you CPA is up 40%, the conversion rate collapsed while impressions held flat, and the likely cause is a broken conversion import, not an auction shift. One of those a media buyer can act on before coffee. The other sends them into the UI for twenty minutes to figure out what happened.
The agent also does something a script can’t: it writes a short, human-readable remediation line in natural language and decides whether to suppress the alert based on what it already sent this week. That’s the part that needs a language model. The detection is just math.
The four-component stack: Claude Code, Google Ads MCP, DuckDB, and a Slack webhook
The whole stack is four things:
- Claude Code as the agent runtime. It runs the nightly job, calls the MCP, queries the baseline store, and writes the Slack messages.
- The Google Ads MCP server for read-only data pulls. MCP, the Model Context Protocol, is Anthropic’s open standard for connecting models to tools and data (spec here). The MCP server exposes Google Ads as a tool the agent can call.
- DuckDB as the baseline store. A single file on disk holding the last 14 days of campaign and ad-group metrics. No Postgres, no warehouse.
- A Slack incoming webhook for delivery. One URL, one POST per alert.
That’s it. No Airflow, no vendor dashboard, no Kubernetes. A cron line on a small VM runs the agent once a night.
Read-only MCP scopes and what the agent must never auto-execute
The Google Ads MCP server connects with OAuth credentials (OAuth being the standard Google uses to grant scoped API access). Scope it to read-only Google Ads API access. That’s the governance line. The agent can pull campaign, ad group, and metrics resources, plus change history. It cannot call any mutate endpoint. If you want to be paranoid, use a separate Google account for the agent’s OAuth, with read-only access to the MCC (My Client Center, Google’s multi-account container), so the credential physically cannot pause a campaign even if the code tried.
This matters because the next thing an eager engineer will ask is “can the agent just pause the bleeding campaign?” The answer is no. Not yet. The recommend-vs-execute line is what makes the bot safe to leave running.
Why DuckDB beats a Postgres baseline for this job
DuckDB runs in-process. The baseline file lives next to the agent script. No server, no connection pool, no migrations. For 14 days of daily campaign metrics across a few hundred campaigns, the file stays small. SQL works the same as Postgres for the queries you need: median, percentile, group-by. When you outgrow it, you’ll know, because the agent will be slow and the file will be huge. That’s not happening on this workload.
Cron cadence: why nightly, not hourly
Nightly is right for most accounts. Google Ads data finalizes with a lag, conversion imports can take hours to settle, and most failure modes a media buyer can act on don’t need a 3 AM page. Run the cron at 6 AM in the account’s primary time zone. The buyer sees triaged alerts when they open Slack, not in the middle of the night.
Hourly cadence is for accounts where intra-day budget burnthrough is real money. Don’t start there. Start nightly, see what fires, then decide if you need faster.

What baseline math should the agent use, and is z-score really wrong?
Use a rolling 14-day median with median absolute deviation (MAD) as the spread, and flag anything where |today - median| / MAD > 3.5. Z-score isn’t wrong in theory, it’s wrong in practice on ads data. One bad day, a tracking outage, a budget pause, a holiday, inflates the standard deviation and hides the next anomaly. Median and MAD are robust to outliers, which is the entire point.
Match the baseline on day-of-week. Monday compares to the last two Mondays. Saturday to the last two Saturdays. Lead-gen and ecommerce accounts both have weekly seasonality you don’t want to flag as an anomaly.
Metrics to score per campaign: cost, clicks, impressions, conversions, conversion rate, CPA, and CPC (cost per click). That’s the core. You can add impression share and search lost-to-budget if the MCP exposes them.
Dead-campaign and cold-start suppression rules
Before the anomaly math even runs, drop two kinds of campaigns from the input:
- Status is not ENABLED. Paused campaigns will look anomalous because they stopped spending. That’s not news.
- Fewer than a meaningful threshold of impressions in the baseline window. “Meaningful” depends on your account. For a small lead-gen account with a long tail of low-volume campaigns, 100 impressions per day might be the floor. For a large ecommerce account, 1,000. Pick a number and write it down. Below the floor, the math is too noisy to trust.
This one rule eliminates most of the ghost alerts new builds generate in week one.
Severity tiers mapped to Slack delivery
Bucket the MAD multiple into three tiers. The exact cutoffs are an operator decision, not a universal truth. A reasonable starting point:
| MAD multiple | Tier | Slack delivery |
|---|---|---|
| 3.5 to 5 | FYI | Thread reply on the day’s digest message |
| 5 to 8 | Alert | Channel post |
| Over 8 | Page | Channel post with @here or a PagerDuty handoff |
Tune the cutoffs after two weeks of running. If the FYI tier fires more than five times a night, raise the floor. If the page tier never fires, lower the ceiling.
How do you stop the agent from spamming Slack every night the same campaign keeps bleeding?
Use a deduplication hash keyed on (campaign_id + metric + direction + ISO_week_bucket), store it when you fire an alert, and skip any anomaly whose hash already exists. This is the centerpiece of the whole build, and it’s where most projects fail.
The naive key is (campaign_id + metric + direction). Campaign 12345, CPA, up. You fire the alert Tuesday. The campaign keeps bleeding Wednesday. The same hash regenerates, you check “have I sent this?”, and you have. So you suppress. That part works.
The problem comes on day eight, when the anomaly is still active but it’s been a full week. Has the situation changed? Should the buyer look again? With the naive key, the answer is no, ever, unless the anomaly closes and reopens. Buyers don’t trust an agent that goes quiet on a real problem for two weeks.
The week-bucket fix and exact hash spec
Add an ISO week bucket to the key:
“` hash = sha1(f”{campaign_id}|{metric}|{direction}|{iso_year_week}”) “`
Where iso_year_week is something like 2026-W14. Now the agent fires once when the anomaly opens, stays quiet for the rest of that week, and re-fires automatically when the calendar rolls into a new ISO week if the anomaly is still active. That’s the moment a human should look again anyway.
Three additional re-fire conditions override the suppression:
- Direction flips. CPA was up, now it’s down. New hash, new alert.
- Severity tier jumps. It was an FYI, now it’s a page. The shape changed enough to interrupt.
- A new metric breaks on the same campaign. Cost was the original anomaly; now conversions also break. New metric, new hash, new alert.
Store every hash in a DuckDB table with the timestamp, the tier, and the message you sent. That table is also your audit log.
When the agent is allowed to re-fire on the same anomaly
In plain English: the agent re-fires when something genuinely changed. A new week, a new direction, a new severity, a new metric. Not just “it’s still bad.” If the only news is “the bleeding continues,” the agent stays quiet. The buyer already knows. Sending the same alert nightly is how you teach the team to mute the channel.
How does the agent tell a tracking break apart from a real CPA spike?
Before writing any Slack message, the agent classifies the anomaly into one of five branches based on which metrics moved together. The classification drives the message. The five branches:
- Budget exhaustion. Cost flat at daily cap, impression share lost-to-budget jumped, impressions fell late in the day.
- Auction or CPC shift. CPC up, conversion rate roughly stable, CPA up proportionally.
- Conversion tracking break. Conversion rate collapsed (less than 40% of baseline), impressions and CPC roughly stable, conversions fell off a cliff.
- Seasonality or external event. All campaigns in a category move together. The agent can flag this as account-wide, not campaign-specific.
- Creative or landing-page fatigue. CTR drifting down over multiple days, conversion rate slowly declining, CPC slowly rising. This one usually shows up in the FYI tier, not as an emergency.
The tracking-break signal: conversion rate collapse with impression stability
The most valuable classification is also the simplest. If conversion_rate_today < 0.4 * conversion_rate_baseline AND impressions are within 20% of baseline AND CPC is within 20% of baseline, the agent labels it a tracking break and the Slack message says:
Likely cause: conversion tracking. Check that the conversion tag fires on the thank-you page, verify the GA4 conversion import is healthy, and look at change history for any conversion-action edits in the last 48 hours.
That's actionable at 7 AM. The buyer goes to one specific place and either confirms or rules it out in five minutes. Compare with: "CPA is up 47% on campaign Spring-Search-Phoenix." The buyer has to do the diagnostic themselves.
For a reference on conversion-tracking failure modes, the Enhanced Conversions for Leads diagnostic we wrote up is worth keeping in the agent's prompt context as reference material, along with Google's conversion troubleshooting doc.
What Claude is allowed to recommend vs what stays human-only
Claude writes the remediation line in free text, with one rule baked into the system prompt: suggest, never command. Allowed phrasings:
- "Check whether the shared budget on campaign group X is exhausting before evening hours."
- "Verify the gtag fires on /thank-you and the GA4 conversion import is healthy."
- "Review change history for bid edits in the last 48 hours."
Banned phrasings:
- "Pause campaign X."
- "Raise the Target CPA to $Y."
- "Shift budget from campaign A to campaign B."
The agent has no authority to act. The line is enforced two ways: in the system prompt ("never recommend a mutating action, only a diagnostic step"), and in the MCP scope (the agent literally cannot call a mutate endpoint, so even a hallucination can't break anything). Belt and suspenders.
What scopes should the Google Ads MCP have, and what should the agent never be allowed to do?
The Google Ads MCP gets read-only access to campaign, ad group, ad, keyword, and metrics data, plus change history. It does not get the developer token's mutate scope, it does not get billing access, and the OAuth credential it uses is a separate account from any human's account.
The agent is never allowed to:
- Pause, enable, or remove any entity.
- Change bids, budgets, or targets.
- Edit conversion actions or attribution settings.
- Send messages to anyone except the configured Slack channel.
- Write to anything other than the local DuckDB file.
Log every MCP call to a local file. When you do the weekly review (you will, see below), having a record of what the agent looked at is how you debug why it missed something or why it fired on something it shouldn't have. The same principle applies whether you're building this in Claude Code or comparing Claude Code with n8n for the orchestration layer.
The Slack message anatomy
A useful alert has four parts in this order:
- Severity prefix.
[FYI],[ALERT],[PAGE]. Buyer can filter by prefix. - Campaign and metric, with magnitude. "Campaign: Spring-Search-Phoenix. CPA up 47% vs 14-day baseline ($48 to $71)."
- Classified likely cause. One of the five branches, in plain English.
- One concrete next action. Not three. One.
Delivery: FYI goes as a thread reply on a single morning digest message, so the channel stays clean. Alert posts to the channel. Page posts to the channel with @here or, if you've wired it, hands off to PagerDuty.
Rough cost per nightly run
Token spend is small. A nightly run scoring a few hundred campaigns and writing a handful of alert messages costs cents, not dollars, at current Claude API pricing. The real ceiling is Google Ads API quota, not money. Read the Google Ads API rate limits doc before you start pulling metrics for hundreds of campaigns nightly.
Where humans stay in the loop, and when to graduate past this build
The agent recommends. A human executes. That's the governance line and it doesn't move.
Weekly, sit down with the audit log for 20 minutes. Three questions:
- Which alerts did the buyer act on? Those classifications worked.
- Which alerts got ignored? Those tiers are too sensitive, or those classifications were wrong.
- Which real problems did the agent miss? Those are the gaps in the baseline math or the suppression logic.
Tune the MAD threshold, the severity tier cutoffs, and the dead-campaign floor based on what you find. The agent gets better the longer you run this loop. Without it, the bot drifts and the team mutes it within a month.
When to graduate to a real observability stack
This build holds up to roughly the scale of a single account with a few dozen active campaigns. Past that, three things start to break:
- The DuckDB file gets big enough that nightly queries slow down. Move to a proper warehouse.
- You need MCC-level (multi-account) consolidation. The MCP server can do it, but the agent's reasoning gets noisy across dozens of accounts. You probably want per-account agents at that point, with a meta-agent summarizing.
- You need real-time, not nightly. That's a different architecture. Streaming, not batch.
When is nightly the wrong cadence?
Nightly is wrong when same-day budget burnthrough costs more than the engineering time to build something faster. For most accounts, that's not the case. For an account spending six figures a day on a single campaign, it absolutely is. If your spend pattern means a 3 PM problem costs five figures by midnight, you need an hourly or even continuous monitor, and the architecture changes.
The other case for faster cadence: accounts running aggressive journey-aware bidding or other Smart Bidding modes that can shift behavior intra-day. Nightly catches the result the next morning. Faster catches it before the day closes.
Frequently Asked Questions
What does a nightly Google Ads anomaly agent actually do that a Google Ads script can't?
A nightly agent classifies the likely cause of an anomaly and writes a remediation suggestion. A script only detects. A detector tells you CPA jumped 40%. The agent tells you CPA jumped 40% because conversion rate collapsed while impressions held flat, points at the conversion tag, and suggests checking the GA4 import. That triage step is the difference between an alert a buyer acts on and an alert that sends them into the UI for twenty minutes.
What baseline math should the agent use, and is z-score really wrong?
Use a rolling 14-day median with median absolute deviation, threshold around 3.5, matched on day-of-week. Z-score isn't conceptually wrong, but a single bad day inflates the standard deviation and hides the next real anomaly. Median and MAD are robust to outliers, which is exactly the property you need on ads data where one tracking outage or budget pause shouldn't ruin your baseline for two weeks.
How do you stop the agent from spamming Slack every night the same campaign keeps bleeding?
Key the deduplication hash on campaign_id, metric, direction, AND ISO week bucket. Without the week bucket, the agent suppresses forever once it sees the same anomaly. With it, the agent fires once when the issue opens, stays quiet for the rest of the week, and re-fires automatically when the week rolls over if the problem persists. Direction flips, severity tier jumps, or a new broken metric on the same campaign override the suppression.
How does the agent tell a tracking break apart from a real CPA spike?
A conversion rate that drops below 40% of baseline while impressions and CPC stay within 20% of baseline is almost always a tracking break. A real CPA spike usually shows up as rising CPC with stable conversion rate, which is an auction story, not a tracking story. The agent classifies into one of five branches (budget, auction, tracking, seasonality, creative) and the Slack message reflects the classification, so the buyer gets a specific next step instead of a number.
What scopes should the Google Ads MCP have, and what should the agent never be allowed to do?
Give the MCP read-only Google Ads API access and nothing else. The agent should never pause campaigns, change bids, edit budgets, or modify conversion actions. Enforce the line in two places: the system prompt forbids recommending mutating actions, and the MCP scope physically cannot call a mutate endpoint. If you want a third layer, use a separate Google account for the agent's OAuth with read-only MCC access.
When is nightly the wrong cadence?
Nightly is wrong when intra-day budget burnthrough costs more than the engineering to build something faster. For most accounts, nightly is fine. For accounts spending six figures a day on a single campaign, or running aggressive Smart Bidding modes that shift behavior intra-day, you need hourly or continuous monitoring. The architecture changes when you go real-time; this build is a batch job.
If you want a second set of eyes on the suppression logic, the MCP scoping, or where the human approval gates belong in your specific account structure, that's the kind of conversation we have on a free consultation. We've built and audited enough of these to know which design decisions matter and which ones look clever but break by week three.
Book a free consultation with Elevarus and we'll walk through your current monitoring setup, where the alert-fatigue risk lives, and what a sensible first version looks like for your account.