The Claude Skill That Writes My Google Ads Copy

Alejandro Rioja
Alejandro Rioja
8 min read
TL;DR

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.

Free newsletter

Every Wednesday. 28,400+ operators. Zero fluff.

[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:

  1. 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.
  2. 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:

  1. 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.
  2. A developer token, requested from your Google Ads manager (MCC) account under Admin → API Center.
  3. 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

code
.claude/skills/google-ads/
  SKILL.md          ← instructions Claude reads
  index.ts          ← the actual tool implementation
  limits.ts         ← character-limit validation

SKILL.md:

markdown
# 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 check

The 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

typescript
// .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

code
> check my Google Ads from the last 7 days

Claude 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.

Advertisement
Keep reading

Related posts

Keep reading

Get the AI playbook in your inbox

Every Wednesday. 28,400+ operators. Zero fluff.

↵ ↵ to see all results esc esc to close