The Claude Skill That Writes My Google Ads Copy
I built a Claude skill that queries the Google Ads API directly with GAQL, flags responsive search ads with weak CTR or high CPA, and rewrites the headlines and descriptions inside Google's exact character limits (30 and 90). It creates the new ad as PAUSED — I review and enable it myself. The part that actually matters: Google's character limits are the real constraint, not the copy. A model that doesn't count characters correctly produces ads Google silently truncates or rejects.
Every Wednesday. 28,400+ operators. Zero fluff.
✓ Check your inbox — click the confirmation link to complete sign-up.
✓ You're subscribed!
✓ You're already on the list.
[Operator’s read] I already run a Claude skill that manages my Facebook ads — that build is here. Google Ads was the harder follow-up, not because the API is worse, but because responsive search ads have hard character limits Facebook doesn’t: 30 characters per headline, 90 per description. A rewrite that reads fine and gets rejected or truncated at serve time isn’t a rewrite, it’s a bug. Most of the work on this skill went into forcing the model to respect that constraint instead of trusting it to.
Table of contents
Open Table of contents
Why Google Ads needed a different build than Facebook
The Facebook skill reads performance data, rewrites copy, and ships a new ad set. Google Ads does the same three jobs, but two things are different:
- The query language is GAQL, not REST filters. Google Ads API v18 runs on Google Ads Query Language — SQL-shaped, read-only, built for exactly this kind of reporting pull.
- Responsive search ads have hard limits. Up to 15 headlines (30 characters each) and 4 descriptions (90 characters each) per ad. Google doesn’t warn you gracefully — it either rejects the asset at submission or, worse, serves a truncated version that reads wrong to a real customer.
Point 2 is the one that breaks naive implementations. Ask a model to “write a punchier headline” and it will cheerfully hand back 34 characters. Google Ads either bounces that asset or renders it clipped mid-word. I don’t find out until I’m looking at a live ad that ends in “Book Your Appointm—”.
Getting API access (the part everyone underestimates)
Three things, and the third one is the one that costs you a week if you don’t plan for it:
- A Google Cloud project with OAuth2 credentials — client ID, client secret, and a refresh token scoped to
https://www.googleapis.com/auth/adwords. Generate the refresh token once via the OAuth playground or a short local script; after that it’s a stored credential, not a login flow. - A developer token, requested from your Google Ads manager (MCC) account under Admin → API Center.
- Access level. Every new developer token starts at Test Account access — it only works against Google Ads test accounts, not your real one. You apply for Basic Access (up to 15,000 operations/day, enough for this) through a form Google reviews manually. Budget a few business days for approval before you plan to go live on a real account.
Store GOOGLE_ADS_CLIENT_ID, GOOGLE_ADS_CLIENT_SECRET, GOOGLE_ADS_REFRESH_TOKEN, GOOGLE_ADS_DEVELOPER_TOKEN, and GOOGLE_ADS_CUSTOMER_ID (the 10-digit account ID, no dashes) in your .env. If you manage the account through an MCC, you also need GOOGLE_ADS_LOGIN_CUSTOMER_ID set to the manager account ID — queries get rejected with a permissions error without it, and the error message doesn’t say why.
The skill file structure
.claude/skills/google-ads/
SKILL.md ← instructions Claude reads
index.ts ← the actual tool implementation
limits.ts ← character-limit validationSKILL.md:
# Google Ads Manager Skill
Use this skill when the user says "check my Google Ads", "run ads
report", or "rewrite underperforming RSAs". Never run this without
explicit user instruction — it touches live ad spend.
## What it can do
- Pull campaign and ad group performance via GAQL (last 7 or 30 days)
- Flag responsive search ads with CTR < 3% or CPA above the stored
target as underperformers
- Rewrite headlines (≤30 chars) and descriptions (≤90 chars) for
flagged ads in Ale's voice
- Create new ad variations as PAUSED — never enabled automatically
## What it will NOT do
- Change budgets or bids without explicit confirmation
- Enable a paused ad
- Submit any headline or description that fails the character checkThe last line in “What it will NOT do” is the one I added after the skill shipped a 32-character headline in week one. The model’s own count was wrong by two characters — close enough to pass a glance, far enough to get the asset rejected. Everything after that runs through a hard validator, not the model’s self-report.
The core TypeScript code
// .claude/skills/google-ads/index.ts
import { GoogleAdsApi } from "google-ads-api";
import Anthropic from "@anthropic-ai/sdk";
const client = new GoogleAdsApi({
client_id: process.env.GOOGLE_ADS_CLIENT_ID!,
client_secret: process.env.GOOGLE_ADS_CLIENT_SECRET!,
developer_token: process.env.GOOGLE_ADS_DEVELOPER_TOKEN!,
});
const customer = client.Customer({
customer_id: process.env.GOOGLE_ADS_CUSTOMER_ID!,
login_customer_id: process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID,
refresh_token: process.env.GOOGLE_ADS_REFRESH_TOKEN!,
});
interface AdPerformance {
adGroupAdId: string;
campaignName: string;
headlines: string[];
descriptions: string[];
impressions: number;
clicks: number;
costMicros: number;
conversions: number;
ctr: number;
cpa: number;
}
async function getAdPerformance(days = 7): Promise<AdPerformance[]> {
const rows = await customer.query(`
SELECT
ad_group_ad.ad.id,
campaign.name,
ad_group_ad.ad.responsive_search_ad.headlines,
ad_group_ad.ad.responsive_search_ad.descriptions,
metrics.impressions,
metrics.clicks,
metrics.cost_micros,
metrics.conversions
FROM ad_group_ad
WHERE segments.date DURING LAST_${days === 30 ? "30" : "7"}_DAYS
AND ad_group_ad.status = 'ENABLED'
`);
return rows.map((r: any) => {
const impressions = Number(r.metrics.impressions ?? 0);
const clicks = Number(r.metrics.clicks ?? 0);
const cost = Number(r.metrics.cost_micros ?? 0) / 1_000_000;
const conversions = Number(r.metrics.conversions ?? 0);
return {
adGroupAdId: r.ad_group_ad.ad.id,
campaignName: r.campaign.name,
headlines: (r.ad_group_ad.ad.responsive_search_ad?.headlines ?? []).map((h: any) => h.text),
descriptions: (r.ad_group_ad.ad.responsive_search_ad?.descriptions ?? []).map((d: any) => d.text),
impressions,
clicks,
costMicros: Number(r.metrics.cost_micros ?? 0),
conversions,
ctr: impressions > 0 ? (clicks / impressions) * 100 : 0,
cpa: conversions > 0 ? cost / conversions : Infinity,
};
});
}
const HEADLINE_MAX = 30;
const DESCRIPTION_MAX = 90;
function validateAsset(text: string, max: number): boolean {
return text.length <= max;
}
async function rewriteRsa(
ad: AdPerformance,
targetCpa: number,
): Promise<{ headlines: string[]; descriptions: string[] }> {
const anthropic = new Anthropic();
const msg = await anthropic.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
messages: [{
role: "user",
content: `Rewrite this Google Ads responsive search ad in Alejandro Rioja's voice: direct, operator-focused, specific. It's underperforming — CTR ${ad.ctr.toFixed(2)}%, CPA $${ad.cpa.toFixed(2)} vs a $${targetCpa} target.
Current headlines: ${JSON.stringify(ad.headlines)}
Current descriptions: ${JSON.stringify(ad.descriptions)}
Rules, non-negotiable:
- Each headline: 30 characters MAXIMUM, including spaces and punctuation
- Each description: 90 characters MAXIMUM, including spaces and punctuation
- Return 5 headlines and 4 descriptions
- Return JSON only: {"headlines": ["...", ...], "descriptions": ["...", ...]}`,
}],
});
const text = (msg.content[0] as any).text.replace(/```json\n?/, "").replace(/```/, "").trim();
const parsed = JSON.parse(text);
const headlines = parsed.headlines.filter((h: string) => validateAsset(h, HEADLINE_MAX));
const descriptions = parsed.descriptions.filter((d: string) => validateAsset(d, DESCRIPTION_MAX));
if (headlines.length < 3 || descriptions.length < 2) {
throw new Error("Model returned too many over-limit assets — rerun or revise manually.");
}
return { headlines, descriptions };
}Every headline and description gets checked against the limit in code, after generation, before anything touches the API. The model’s rewrite is a draft. The validator is the gate.
How I use it day-to-day
> check my Google Ads from the last 7 daysClaude runs the GAQL pull, flags anything under 3% CTR or over my stored CPA target, and asks before rewriting. I confirm. It generates new headlines and descriptions, runs them through the character validator, drops anything that fails, and creates a new ad group ad in PAUSED status through the Google Ads API’s mutate operation. I review the new copy against the old inside Google Ads, enable the ones I’d actually run, and leave the rest paused.
Same ceiling as the Facebook version: roughly 20 minutes instead of an afternoon in the UI, because the mechanical part — pulling the data, drafting within a hard constraint, staging the change — is exactly what a model should be doing instead of me.
What the validator doesn’t catch
Character count is mechanical. Relevance isn’t. A headline can be 28 characters, pass every check, and still be the wrong message for the search term it’s matched to. The skill can’t tell me whether an underperforming ad group has a bad offer, a landing page mismatch, or genuinely weak copy — it can only tell me the copy is weak relative to the ads around it. If every ad in a campaign has a low CTR, that’s a targeting or landing-page problem, and feeding the skill more rewrite cycles on broken intent match just produces more compliant, still-ignored copy.
I read the GAQL output myself before approving a rewrite batch, the same way I do on the Facebook side. The skill removes the typing and the character-counting. It doesn’t remove the judgment call about what’s actually broken.
The tools I use to run this
Claude is what both ad skills run on — the Facebook one and this one share the same rewrite-and-validate pattern, just against a different API and a tighter character budget.
Google Ads API (google-ads-api npm package) — the TypeScript wrapper around the raw gRPC/REST API. It handles OAuth refresh and GAQL query building so you’re not hand-rolling protobuf.
FAQ
Do I need Basic Access, or does Test Account access work?
Test Account access only runs against Google Ads test accounts — fine for development, useless for a real account with real spend. You need Basic Access (apply through API Center in your manager account) before this skill can touch a live campaign. Approval is manual review, not instant, so apply before you plan to need it, not the week you want to ship.
Why 5 headlines and 4 descriptions instead of the full 15 and 4 maximums?
Google allows up to 15 headlines, but Ad Strength and real-world testing both suggest diminishing returns past 8–10 genuinely distinct angles — most accounts end up with several near-duplicate headlines padding out the count. I cap the model’s output at 5 new headlines per rewrite batch so I’m reviewing distinct options, not filtering nine variations of the same sentence.
What happens if the model’s rewrite fails the character check on every attempt?
The validator throws rather than silently truncating or submitting an over-limit asset. In practice this means I rerun the prompt once, and if it fails twice I write that specific headline by hand. It happens more often on descriptions — 90 characters is a tighter fit for a full sentence than it looks.
Related: for read-only reporting across GA4, Search Console, Bing, Ahrefs, and Core Web Vitals in one Claude connector instead of building a bespoke skill per platform, I run SiteWide MCP — same idea of pointing Claude at real marketing data, wider surface than one ad platform.
The operator’s bottom line
If you’re hand-writing responsive search ad variations and counting characters yourself, that’s exactly the kind of mechanical, rule-bound task a model should own. The part that takes actual judgment — is this a copy problem or a targeting problem — stays yours. Build the skill, let it draft and validate against Google’s limits, and keep the diagnosis for yourself.
Every Wednesday. 28,400+ operators. Zero fluff.
✓ Check your inbox — click the confirmation link to complete sign-up.
✓ You're subscribed!
✓ You're already on the list.
Related posts
Claude Cowork Scheduled Tasks: The Setup Guide
Claude Cowork's scheduled tasks turn a one-time prompt into a recurring job that fires on its own — no code, no server. Here's how to set one up right.
AI AgentsAI Agent Services Agencies Can Sell in 2026
Six AI agent automations I run in my own business, reframed as service line items agencies can scope, price, and sell to clients.
AI AgentsAI Agents for SaaS: What to Automate First
The tier-and-ROI framework I use for AI agent decisions, applied to the SaaS workflows actually worth automating first — and the ones I'd leave to a human.
Get the AI playbook in your inbox
Every Wednesday. 28,400+ operators. Zero fluff.
Check your inbox.
We sent you a confirmation email — click the link inside to complete your subscription. Check spam if you don't see it within a minute.
You're subscribed.
Welcome — the next edition lands in your inbox soon.
You're already on the list — look for it every Wednesday.