Quick Answer
Automating Google Ads with Claude Code means letting the agent decide what to advertise — keywords, copy, budget, match types — while a deterministic Python script using the google-ads-python client makes every API call. The Google-Ads-specific work is the part a generic ad-automation setup never shows you: building a five-level hierarchy (CampaignBudget → Campaign → AdGroup → keyword criteria → ad), setting budgets in micros (1,000,000 micros = one currency unit), reading performance back with GAQL, and authenticating with a developer token plus an OAuth refresh token plus a login_customer_id. Every campaign is created PAUSED. This post is the deep dive into each of those.
This is the follow-up to How I Automated Ad Campaigns with Claude Code, which covered the two-layer idea — skills for judgment, scripts for execution — across both Meta and Google. Here I go down one platform: Google Ads, where the API is stricter, the budget unit is a trap, and reporting is a query language.
Who This Guide Is For
- Solo developers running their own Google Search or App campaigns who want to script the setup-and-measure loop
- People who read the first post and want the Google-Ads-specific layer underneath it
- Anyone who hit the Google Ads API and discovered it is stricter, more hierarchical, and weirder than the Meta Graph API
If you have never seen the two-layer pattern — Claude Code skills handle language and judgment, Python scripts make every real API call — read the first post first. This one assumes it and zooms in.
What Google Ads Makes Harder
The generic two-layer post treats “the ad API” as one box. Google Ads is a different box from Meta in five ways that all leak into your code:
| Concern | Meta-style ad API | Google Ads API |
|---|---|---|
| Budget unit | Currency minor units | Micros (×1,000,000), as a separate CampaignBudget entity |
| Structure | Campaign → Ad Set → Ad | Mandatory 5-level chain: Budget → Campaign → AdGroup → Criterion → Ad |
| Reporting | A flat insights endpoint | GAQL, a SQL-like query language |
| Text limits | Character counts | Byte counts (multibyte characters cost more) |
| Auth | One token | Developer token + OAuth refresh token + login_customer_id |
None of these are hard once you know them. All of them silently waste an afternoon the first time. The deterministic script layer exists to encode each one exactly once so the agent never re-derives it.
The Google Ads Hierarchy
You cannot create “an ad.” You create a tree, bottom dependency first:
CampaignBudget # amount_micros, delivery_method, explicitly_shared
└─ Campaign # PAUSED, channel type, bidding strategy
└─ AdGroup # the bid container
└─ AdGroupCriterion # keywords + match types, negative keywords
└─ AdGroupAd # the responsive search ad (assets)My scripts build this with sequential mutate calls: create the budget, read back its resource_name, pass that into the campaign, read back the campaign’s resource_name, and so on down the tree. Each level is its own MutateXxx call with an operation object whose .create field you populate.
The API also supports building the whole tree in one atomic mutate using negative temporary resource names (customers/{id}/campaignBudgets/-1, then -2 for the campaign that references it). I do sequential calls instead: a per-resource call gives a cleaner failure point, and because the campaign is created PAUSED anyway, atomicity buys little. The trade-off is more round-trips for clearer errors — worth it when an agent is assembling the arguments.
The Workflow
Step 1: Authenticate with three secrets, not one
The Google Ads client wants a developer token, a full OAuth pair with a refresh token, and the manager account id. The script loads them from .env and builds the client once:
config = {
"developer_token": os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN"),
"client_id": os.getenv("GOOGLE_ADS_CLIENT_ID"),
"client_secret": os.getenv("GOOGLE_ADS_CLIENT_SECRET"),
"refresh_token": os.getenv("GOOGLE_ADS_REFRESH_TOKEN"),
"login_customer_id": os.getenv("GOOGLE_ADS_LOGIN_CUSTOMER_ID"),
"use_proto_plus": True,
}
client = GoogleAdsClient.load_from_dict(config)login_customer_id is the manager (MCC) account that has access to the account you are mutating. For a standalone account it equals the working customer id. Get it wrong and you get not_a_manager. The refresh token is the part that rots: an invalid_grant means it expired or was revoked, and re-auth is a manual OAuth flow you cannot automate away.
Step 2: Create the budget in micros
This is the one number that can hurt you. Google Ads money is in micros, so you multiply by a million:
budget = operation.create
budget.amount_micros = int(daily_budget * 1_000_000) # $10/day -> 10_000_000
budget.delivery_method = client.enums.BudgetDeliveryMethodEnum.STANDARD
budget.explicitly_shared = FalseForget the * 1_000_000 and you set a budget of $0.00001 — the campaign just never delivers and you waste a day wondering why. Pass a currency amount where micros belong from a different code path and you can do the opposite. The unit is off from human intuition by six zeros, every time. Always cast with int(...) so float precision does not shave the last digits.
Step 3: Create the campaign — PAUSED, and mind the required fields
campaign.status = client.enums.CampaignStatusEnum.PAUSED
campaign.advertising_channel_type = client.enums.AdvertisingChannelTypeEnum.SEARCH
campaign.contains_eu_political_advertising = (
client.enums.EuPoliticalAdvertisingStatusEnum
.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING
)That last field is not optional. As of API v23, contains_eu_political_advertising is required, and a script written against an older version breaks the day you upgrade with a cryptic “required field” error. This is the kind of moving target the script layer is for: pin it once, in code, with a comment saying why.
Bidding strategy is its own object on the campaign — there is no single “bid” field. You pick one:
campaign.target_cpa.target_cpa_micros = int(target_cpa * 1_000_000) # Target CPA
# or
campaign.target_spend.cpc_bid_ceiling_micros = int(ceiling * 1_000_000) # Max clicksStep 4: Keywords and match types
Search campaigns live or die on keyword match types. Each keyword is an AdGroupCriterion:
criterion.keyword.text = "cat health tracking app"
criterion.keyword.match_type = client.enums.KeywordMatchTypeEnum.PHRASE # or BROAD, EXACT- BROAD — matches related searches in any order; cheap reach, expensive waste
- PHRASE — matches the phrase in order, with words allowed around it
- EXACT — matches only that term and close variants
Negative keywords are how you stop paying for the wrong clicks, and they live at the campaign level with a negative flag (conventionally BROAD):
criterion.campaign = campaign_resource_name
criterion.negative = True
criterion.keyword.text = "free" # don't pay for "free <product>" searches
criterion.keyword.match_type = client.enums.KeywordMatchTypeEnum.BROADThis is exactly the kind of judgment the skill layer is good at: ask the agent to propose negatives like free, dog, jobs for a paid cat-app — and a script that writes them deterministically.
Step 5: The ad — responsive search ads, with byte limits
Here is a Google quirk that surprises everyone: ad text limits are in bytes, not characters. A responsive search ad allows up to 15 headlines at 30 bytes each and 4 descriptions at 90 bytes each. In ASCII, 30 bytes is 30 characters. In UTF-8, an accented letter or emoji is 2–4 bytes, and a Korean character is 3 — so a 10-character Korean headline already hits the 30-byte cap. The fix is a byte-aware truncator, not Python’s len() (which counts characters):
for text in headlines[:15]:
asset = client.get_type("AdTextAsset")
asset.text = truncate_to_byte_limit(text, 30) # bytes, not chars
ad.responsive_search_ad.headlines.append(asset)The copy-generation skill writes the headlines; the script guarantees they fit the byte budget before they ever reach the API.
Step 6: Everything is created PAUSED — the approval gate
No script in the system activates a campaign. Creation always sets PAUSED. The reason is sharper on Google than anywhere else: the agent chose a budget, that budget is in micros, and an off-by-a-million is one cast away. PAUSED means I read the budget, the keywords, the negatives, and the headlines back out before a single impression serves. The automation assembles; I flip the switch.
Step 7: Read it back with GAQL
Google has no flat “give me my numbers” endpoint. You query with GAQL and stream the results:
query = """
SELECT
campaign.id,
campaign.name,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions,
metrics.cost_per_conversion
FROM campaign
WHERE segments.date DURING LAST_7_DAYS
AND campaign.status != 'REMOVED'
ORDER BY metrics.impressions DESC
"""
for batch in ga_service.search_stream(customer_id=cid, query=query):
for row in batch.results:
cost = row.metrics.cost_micros / 1_000_000 # micros, againTwo things bite here. Enum literals must be quoted ('REMOVED', not REMOVED) or the query errors. And cost metrics come back in micros, so you divide by a million on the way out just like you multiplied on the way in.
Step 8: Evaluate and recommend — never autopilot
A reporting skill pulls the GAQL numbers and an evaluation skill reads them with simple, legible rules:
- 100+ clicks and 0 conversions → recommend pause
- ROAS above target × 1.25 → recommend +20% budget
- ROAS below target × 0.75 → recommend −20% budget
Crucially, these are recommendations, written to a report for me to read. The loop closes with a human, the same as activation. The agent never raises a budget on its own.
Example Project Structure
Genericized, but this is the real shape of the Google side:
marketing-agent/
CLAUDE.md # workflow map + hard rules (campaigns are PAUSED)
.env # developer token, OAuth, login_customer_id
.claude/skills/
google-ads-campaign-creator/SKILL.md # builds the hierarchy
google-copy-generator/SKILL.md # RSA headlines/descriptions
google-ads-reporter/SKILL.md # GAQL -> markdown report
google-ads-bid-optimizer/SKILL.md # ROAS/CPA -> +/-20% recommendations
google-evaluate-campaign/SKILL.md # KPI trend read
google-env-setup/SKILL.md # OAuth / MCC validation
src/
ads_client.py # GoogleAdsClient init + auth diagnostics
campaign_manager.py # budget -> campaign -> ad group -> keywords -> ad
bidding_optimizer.py # performance -> recommendations
reporting.py # GAQL queries -> reports
text_utils.py # truncate_to_byte_limit (UTF-8 byte counting)
assets/brands/<id>/
profile.json # product + target persona
guidelines.md # tone the copy skill readscampaign_manager.py owns the whole mutate sequence and the micros math. text_utils.py exists solely because byte limits are not character limits. And CLAUDE.md is the constitution: it tells the agent the order of operations and the non-negotiable rule that campaigns are created PAUSED.
A Real Project Note
The honest part: not one of these failures was the model being dumb. Every one was the Google Ads API being exacting, and every fix was to pin the rule in the script so the agent could not get it wrong twice.
- The required field that appeared. After bumping the client library, campaign creation started failing on a missing
contains_eu_political_advertising. It became required in v23. The model would never guess a brand-new required enum; the script encodes it. - Bytes, not characters. A Korean headline that read as 11 characters was rejected for exceeding 30 bytes (11 × 3 = 33).
len()lies here.truncate_to_byte_limitwas the fix, and it matters for any multibyte copy — emoji included. login_customer_idand the MCC. Pointing it at the working account instead of the manager account returnsnot_a_manager. One value, encoded once, end of guessing.- Quoted enums in GAQL.
WHERE campaign.status != REMOVEDfails;!= 'REMOVED'works. GAQL looks like SQL right up until the literal quoting bites you. - App campaign constraints. App ads only accept 1:1 and 1.91:1 images (others throw
ASPECT_RATIO_NOT_ALLOWED), allow one app ad per ad group, cannot be removed directly (AD_TYPE_CANNOT_BE_REMOVED— you delete the ad group), and the ad group name stays reserved even after it is removed. - The refresh token that rotted. An
invalid_grantout of nowhere meant the OAuth refresh token had expired. There is no automating around that — re-auth by hand and move on.
None of these are AI problems. They are integration problems, and the two-layer design contains them: each quirk got fixed in the script layer once, instead of being re-litigated by the model on every call. The structure protects you, not the model — the same lesson I keep relearning building small web apps with Claude Code.
Common Mistakes
- Treating budgets as currency. It is micros.
int(amount * 1_000_000)or you are off by a million. - Counting characters for ad text. It is bytes. Multibyte copy hits the cap early.
- Letting the agent activate campaigns. Create PAUSED, read it back, then a human flips it. Always.
- Hardcoding the API version’s required fields from memory. Pin them in code with a comment; they move (v23’s EU political flag).
- Unquoted enum literals in GAQL.
'REMOVED', notREMOVED. - Pointing
login_customer_idat the wrong account. It is the manager (MCC), not the working customer.
Checklist
- Incomplete task: Budgets set with int(amount * 1_000_000); values read back before activation Budgets set with
int(amount * 1_000_000); values read back before activation - Incomplete task: Campaigns created PAUSED, activated only by a human Campaigns created
PAUSED, activated only by a human - Incomplete task: Required campaign fields (e.g. EU political advertising) pinned in code with a comment Required campaign fields (e.g. EU political advertising) pinned in code with a comment
- Incomplete task: Ad text truncated by byte length, not character length Ad text truncated by byte length, not character length
- Incomplete task: Keyword match types chosen deliberately; negative keywords set at campaign level Keyword match types chosen deliberately; negative keywords set at campaign level
- Incomplete task: GAQL enum literals quoted; cost metrics divided by 1,000,000 GAQL enum literals quoted; cost metrics divided by 1,000,000
- Incomplete task: Auth uses developer token + OAuth refresh token + login_customer_id Auth uses developer token + OAuth refresh token +
login_customer_id - Incomplete task: Bid changes are recommendations a human approves, not autopilot Bid changes are recommendations a human approves, not autopilot
- Incomplete task: Secrets in .env, never committed Secrets in
.env, never committed
When Not to Use This Approach
If you run one Search campaign and rarely touch it, open Google Ads and click — this is over-engineering. The hierarchy, the micros math, and the GAQL plumbing only pay off when you run the same build-and-measure loop repeatedly across several products and the setup busywork is the bottleneck. And if you cannot yet judge whether a keyword set is sane or whether last week’s CPA was good, automate the mechanics but keep both hands on the approval gate. The system removes typing; it does not remove the need to know what good looks like.
FAQ
Q. What is a micros budget in the Google Ads API?
A. Google Ads expresses all money in micros, where 1,000,000 micros equals one unit of your account currency. A $10 daily budget is amount_micros = 10000000. Forgetting the multiplier is the classic footgun, which is exactly why every campaign should be created PAUSED so you can read the budget back before it spends.
Q. Do I need GAQL to automate Google Ads reporting?
A. Yes. Google Ads has no flat insights endpoint like Meta. You read performance with GAQL (Google Ads Query Language), a SQL-like syntax: SELECT metrics.clicks, metrics.cost_micros FROM campaign WHERE segments.date DURING LAST_7_DAYS. Enum literals must be quoted, and cost fields come back in micros.
Q. Why create Google Ads campaigns in a PAUSED state?
A. Because the agent picks the budget and the budget is in micros, a single off-by-a-million error can create a live overspend. Creating PAUSED means nothing delivers until a human reads the generated budget, keywords, and copy and flips it on. The automation builds the campaign; it never decides to spend.
Q. What auth does the Google Ads API need beyond an API key?
A. Three things, not one: a developer token (account-level), an OAuth 2.0 client id/secret with a long-lived refresh token, and a login_customer_id for the manager (MCC) account. An invalid_grant error means the refresh token expired or was revoked and you have to re-authenticate by hand.
Related Articles
- Automating Meta Ads with Claude Code: A Deep Dive — the companion deep-dive on the other platform
- How I Automated Ad Campaigns with Claude Code — the two-layer foundation this post builds on
- How I Run a Claude Code Writing Project Like Software
- How I Use Claude Code to Build Small Web Apps
- More posts tagged AI coding
- 내가 AI로 블로그 글을 대량생산하는 워크플로우 (Korean)
Last updated: 2026-06-25. Built against google-ads-python v31 (June 2026); field requirements like the EU political advertising flag move between API versions, so check the release notes when you upgrade.