Build a Claude Code plugin that spies on competitors' live Meta ads, writes 20 on-brand copy variations on command, grades any ad before you spend a dollar, and audits your live account like a senior buyer. Full build, copy-paste ready. If you'd rather not build it, the last section shows where AutoAdy already does each piece.
This is a complete build, not a teaser. By the end you'll have one Claude Code plugin with six skills, a credential vault, and a dashboard renderer that turns every result into a branded HTML page. No prior plugin experience required. If you can copy, paste, and follow instructions, you can ship it.
The plugin in this guide is called ad-room. Rename it to whatever you like.
The one idea that makes this work Claude Code skills are markdown, not code. The "logic" is instructions: you describe the process, the rules, and the output shape, and Claude does the reasoning. Python only shows up where you genuinely need it, which is two places: calling an external API, and writing a file. Everything else is prose in a SKILL.md.
What you're building
| Skill | What it does | Needs |
|---|---|---|
/setup | Collects and live-verifies your scraper token, stores it once | nothing extra |
/draft | 20 on-brand ad copy variations from one reference | nothing |
/grade | Scores any ad 0 to 100 across six dimensions, pre-launch | nothing |
/recon | Pulls a competitor's active ads, ranked by run-time | a scraper (free tier) |
/map | 3 to 5 brands head-to-head plus the angles nobody is running | a scraper (free tier) |
/audit | Full health audit of your live Meta account, 0 to 100 | the official Meta ads MCP (no key) |
Two shared pieces every skill leans on: a key manager that stores credentials in your home directory (never inside the plugin, never in git), and a dashboard renderer that turns any result into a self-contained branded HTML page that opens in the browser. The dashboard is what makes the output feel like a product instead of a wall of chat text.
Prerequisites (10 minutes)
- Claude Code installed (CLI or desktop).
- Python 3.9+ (
python3 --version). - A scraper account with an API token. Several services run Meta Ad Library scrapers on a free tier that covers plenty of scans. You only need this for
/reconand/map. Grab a personal API token from the service's console. - The official Meta ads MCP, only for
/audit. Connect it inside Claude Code's MCP settings. Auth happens in the connection itself, so there's no token to paste and no app review. Meta is rolling this out gradually, so not every ad account has access yet. The other five skills work without it.
Step 1: Scaffold the plugin
A Claude Code plugin is a folder with a manifest and a skills/ directory. Create this:
ad-room/
├── .claude-plugin/
│ └── plugin.json
├── lib/
│ ├── keys.py
│ └── dashboard.py
├── scripts/
│ └── verify_keys.py
└── skills/
├── setup/SKILL.md
├── draft/SKILL.md
├── grade/SKILL.md
├── recon/
│ ├── SKILL.md
│ └── scripts/recon.py
├── map/
│ ├── SKILL.md
│ └── scripts/map.py
└── audit/
├── SKILL.md
└── references/health-checks.md
plugin.json is minimal:
{
"name": "ad-room",
"description": "Meta ads competitor intel, on-brand copy, pre-launch scoring, and live account audits inside Claude Code",
"version": "1.0.0"
}
Every SKILL.md opens with frontmatter that tells Claude when to fire it. The description is the trigger, so write it like you're telling a sharp assistant exactly when to reach for the tool:
---
name: recon
description: Pull every active Meta ad a competitor is running, ranked by run-time. Trigger on "recon [brand]", "what ads is [brand] running", or /recon.
---
Reference any in-skill script with ${CLAUDE_PLUGIN_ROOT} rather than a relative path. After install, the working directory is the user's project, not your plugin folder, and relative paths break. Claude Code swaps in the real path automatically.
Step 2: The credential vault
Rule one of plugin credentials: never store keys inside the plugin folder. Plugin folders get updated, shared, and committed to git. Keys belong in the user's home directory, in a file only they can read.
lib/keys.py:
import os, stat
CRED_DIR = os.path.expanduser("~/.ad-room")
CRED_FILE = os.path.join(CRED_DIR, "credentials.env")
def load():
"""Read saved keys into a dict."""
keys = {}
if os.path.exists(CRED_FILE):
for line in open(CRED_FILE):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, _, v = line.partition("=")
keys[k.strip()] = v.strip()
return keys
def save(new_keys: dict):
"""Merge new keys in, then lock the file to owner-only (chmod 600)."""
keys = load()
keys.update(new_keys)
os.makedirs(CRED_DIR, exist_ok=True)
with open(CRED_FILE, "w") as f:
for k, v in keys.items():
f.write(f"{k}={v}\n")
os.chmod(CRED_FILE, stat.S_IRUSR | stat.S_IWUSR) # 600: only you can read it
def mask(value: str) -> str:
"""Show enough to recognize, never the key itself."""
return f"{value[:4]}...{value[-4:]}" if len(value) > 8 else "set"
scripts/verify_keys.py makes one live API call so a wrong key fails loudly at setup, not silently mid-scan. The most common real-world failure is pasting an account ID where the API token belongs, so the message should name that suspicion out loud:
import json, os, sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib"))
import requests, keys
def verify(token):
r = requests.get("https://api.<your-scraper>.com/v2/users/me",
headers={"Authorization": f"Bearer {token}"}, timeout=15)
if r.status_code == 200:
return True, "valid"
return False, f"rejected (HTTP {r.status_code}), did you paste the account ID instead of the API token?"
creds = keys.load()
if "SCRAPER_TOKEN" in creds:
ok, detail = verify(creds["SCRAPER_TOKEN"])
print(json.dumps({"ok": ok, "detail": detail}, indent=2))
The /setup SKILL.md is just instructions: check status, ask for the token if it's missing, save it through the key manager, verify with a live call, then confirm with a masked value. Three rules to write into it:
- Never echo the key back in chat. Save, verify, confirm masked.
- Always verify with a live call. A silently wrong key is worse than a missing one.
- Collect only what's needed. This stack needs exactly one token.
/auditdeliberately needs none, because the Meta MCP handles its own auth.
Step 3: The dashboard renderer
This is the highest-leverage component in the whole plugin. Every skill's output funnels through one Python script that turns a JSON payload into a self-contained branded HTML page and opens it in the browser.
lib/dashboard.py reads a data.json, switches on a kind field ("grade", "recon", "map", "audit"), and renders the matching template. Design choices that make it read like a product rather than a script:
- Self-contained HTML. All CSS inline, zero external dependencies, works offline forever.
- One dark theme. Dark background, one accent color, big score numerals.
- A score ring. A CSS
conic-gradientcircle showing the 0 to 100 number. This is the money shot. - Horizontal bar rows for dimension scores (score over max as a width percentage).
- Severity pills in red, yellow, and gray for findings.
The contract is one command:
python3 lib/dashboard.py --data run/data.json --out run/dashboard.html
A skeleton you can extend per kind:
import argparse, json, html
def render(data):
kind = data["kind"]
body = {"grade": render_grade, "recon": render_recon,
"map": render_map, "audit": render_audit}[kind](data)
return f"""<!DOCTYPE html><html><head><meta charset="utf-8">
<title>{html.escape(data.get('title', 'ad-room'))}</title>
<style>
body {{ background:#12121f; color:#eee; font-family:-apple-system,sans-serif; margin:0; padding:40px; }}
.ring {{ width:160px; height:160px; border-radius:50%;
background: conic-gradient(#4f8cff calc(var(--pct)*1%), #2a2a3e 0); }}
.bar {{ height:10px; border-radius:5px; background:#2a2a3e; }}
.bar-fill {{ height:100%; border-radius:5px; background:#4f8cff; }}
.pill-high {{ background:#e5484d; }} .pill-med {{ background:#f5a623; }} .pill-low {{ background:#3a3a4e; }}
</style></head><body>{body}</body></html>"""
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--data", required=True); p.add_argument("--out", required=True)
a = p.parse_args()
out = render(json.load(open(a.data)))
open(a.out, "w").write(out)
print(f"wrote {a.out}")
Each skill's SKILL.md then ends with the same three lines: write data.json to a dated run folder, call the renderer, open dashboard.html. Claude fills in the JSON from its analysis. The skill thinks; the script renders. Use a dated run folder so scans never collide: runs/<skill>-<slug>-$(date +%Y-%m-%d).
Step 4: /draft, on-brand copy at volume (the zero-setup skill)
Start here. It's pure markdown, no scripts, no keys, value in under two minutes. The SKILL.md teaches Claude a process:
- Look for brand context in
./brand/(voice, forbidden tones, ICP pains). For agencies, check./clients/[name]/brand/. - If no brand files, collect a 60-second inline brief: product, audience and their number-one frustration, voice (bold, friendly, expert, or playful), one reference line, the offer. Do not stop and demand files. A cold user must get a result on the first run.
- Lock the reference. A winning ad, a hook, or the brief. This is a remix machine, not a blank-page generator.
- Generate 20 variations, each distinct on at least one named dimension: hook angle (problem-agitate, curiosity, bold claim, social proof, us-versus-them, founder story, myth-bust, question, transformation), length (roughly 8 short, 8 medium, 4 long), CTA approach, emotional register.
- Pick a top five to test first, one line of reasoning each.
The rules section is what separates this from generic AI copy. Write these as hard constraints:
- Always use voice-of-customer language over marketing-speak.
- Never write "Shop now" or "Limited time offer" as a hook.
- Never violate a brand's forbidden-tones list when files are present.
- Each variation must be distinct on a named dimension, and label it.
Step 5: /grade, the pre-launch scorer
A rubric Claude applies to any ad, whether it's a static, a script, or pasted copy. Six dimensions, 100 points, hook weighted heaviest because the first line is the single biggest driver of paid-social performance:
| Dimension | Max | What it measures |
|---|---|---|
| Hook | 25 | Does the first line or three seconds stop the scroll? Specificity, tension |
| Copy | 20 | Clarity, one-idea focus, believability, voice-of-customer language |
| CTA | 15 | Is the next action obvious, single, low-friction? |
| Offer | 15 | Compelling value framing, risk reversal, stakes |
| Emotional resonance | 15 | Real desire, fear, or identity, not just features |
| Visual fit | 10 | Creative matches message and placement |
Three rules make the scores trustworthy:
- Handle missing dimensions honestly. A copy-only submission means visual fit is "not assessable"; redistribute its 10 points proportionally across the rest. Never silently inflate.
- Every below-80% dimension gets a specific fix, an actual rewritten line, not "make the hook stronger."
- Calibrate hard. Write it into the skill: a generic ad should land in the 40s to 50s; reserve 85-plus for genuinely strong creative. Grade inflation makes the tool useless. If everything you test scores 70-plus, your grader is broken.
Verdict bands: 85 to 100 launch it, 70 to 84 launch-ready with fixes, 55 to 69 needs work, 0 to 54 rebuild. Output is a chat scorecard plus the branded dashboard (kind: "grade" with dimensions, fixes, keep, total, verdict).
Step 6: /recon, competitor intel from the Ad Library
The flagship, and the one honest caveat to bake into the skill itself: in the Meta Ad Library, run-time is the only visible shadow of spend. Brands kill losing ads fast, so an ad still live after 100-plus days is paying for itself. Sort by run-time and the proven winners float up. Write the limit in plainly too: the Ad Library shows live creative, not spend or results. Run-time is a strong proxy, not proof.
skills/recon/scripts/recon.py does four jobs.
1. Call an Ad Library scraper. Most scraper services expose a run-and-poll API. The pattern:
import requests, time
def run_actor(token, actor_id, payload, timeout=300):
r = requests.post(f"https://api.<scraper>.com/v2/acts/{actor_id}/runs",
params={"token": token}, json=payload, timeout=30)
run = r.json()["data"]
while run["status"] in ("READY", "RUNNING"):
time.sleep(5)
run = requests.get(f"https://api.<scraper>.com/v2/actor-runs/{run['id']}",
params={"token": token}).json()["data"]
return requests.get(f"https://api.<scraper>.com/v2/datasets/{run['defaultDatasetId']}/items",
params={"token": token, "clean": "true"}).json()
Input: the brand keyword or page URL, country, active status, max ads (default ~200).
2. Normalize each ad into a flat record: hook (first ~140 chars of creative text), cta, offer (link title or description), destination (landing URL), started, days_running (today minus start), advertiser, is_active.
3. Filter to the target brand. Keyword search is fuzzy: "Ridge Wallet" also returns competitors, creators, and random pages that mention "ridge." Auto-narrow to advertisers whose name contains the brand's distinctive token, report how many off-brand ads you dropped, and offer an --all-advertisers escape hatch. This filter is the difference between intel and noise. "Filtered out 61 off-brand ads" is a feature, not noise.
4. Write outputs: a full JSON, a CSV of table fields, and dashboard.html through the shared renderer, a gallery of ad cards sorted by days-running.
The SKILL.md then tells Claude what to do after the script: present the top ~15 by run-time as a table (Days / CTA / Hook / Offer / Destination), then add the analysis layer: hook patterns across the long-runners, the CTA-plus-offer combo they lean on, and what makes the one to three longest-running ads (their "control") sticky. Quote real hooks. Skip ads with {{product.brand}} template tokens; those are dynamic catalog ads, not human-written hooks.
Step 7: /map, the category map
A loop around the recon engine plus an aggregation layer. map.py imports the same pull-normalize-filter functions, runs them per competitor (~120 ads each, continuing past per-brand failures), and writes a combined JSON plus a per-brand rollup (ad count, longest run, top CTAs). The intelligence lives in the SKILL.md's three-part output:
A. Head-to-head table, competitor / active ads / longest run / top CTAs / signature angle.
B. Angle map, classify each brand's longest-running hooks into buckets: problem-agitate, curiosity gap, social proof or UGC, authority, price or value, transformation, us-versus-them, founder story, fear or urgency, identity or status. Mark each lane crowded, contested, or open.
C. The gaps, which is the payoff. The open lanes are angles nobody in the category is running. Name the two to three highest-opportunity gaps, with real competitor hooks quoted as evidence for every classification. Something like: "Both wallet brands run feature claims and percent-off. Nobody runs UGC. Nobody dramatizes the pain. That's your opening." Re-render the dashboard after the analysis so the angle map and open lanes show in the visual, not just the chat.
Step 8: /audit, the live account audit
The most agency-grade skill, and the only one that touches your real account. It runs entirely through the official Meta ads MCP: no token, no app review, no third-party connector.
Put the scoring rubric in a separate file, references/health-checks.md, not inline in the SKILL.md. It's the single source of truth the skill reads before every audit, so tuning a threshold means editing one file.
The rubric starts at 100 and subtracts per finding (high −8, medium −4, low −2), capping each category's damage at 30 so one noisy area can't zero the score:
- Creative fatigue, per ad: last-7-day CTR fell more than 25% below the 30-day CTR while frequency is over 2.5 (the classic fatigue curve). Account: fewer than five ads delivering is thin-pool risk.
- Frequency and saturation, ad or ad set over 4 (high if over 6); account over 3 (high if over 4).
- Efficiency, per ad: CTR under half the account median (at 1,000-plus impressions); cost-per-result over 2x the account blend.
- Wasted spend, meaningful spend (over max($50, 3% of account)) with zero results.
- Audience overlap, heuristic: three or more ad sets simultaneously above frequency 3. Label it a heuristic.
- Spend concentration, over 70% in one campaign, low severity.
Three hard-won lessons to bake in:
- Detect the account's result type first. Pull account-level insights and read whether results are lead-style (
fb_pixel_lead) or purchase-style (omni_purchase). A lead-gen account has no purchases, so that's never a finding. Never hard-code "purchases." - Know your data source's limits. The Meta MCP rejects
quality_ranking,engagement_rate_ranking, andconversion_rate_rankingas unsupported fields. Don't request them; cover delivery quality with Meta's native diagnostics instead. - Fold in Meta's own diagnostics and attribute them. Meta exposes causally-backed recommendations sorted by points of lift, plus anomaly signals. When your rubric finds nothing but Meta's score is below 100, lead with Meta's number and say plainly: no defects, only upside.
Audit flow: find the account, detect result type, pull insights at account / campaign / ad-set / ad levels (30-day plus 7-day for the fatigue comparison), apply the rubric, fold in Meta's diagnostics, render the dashboard, and finish with a chat recap that groups findings by severity and gives a "fix this week" shortlist tied to dollars.
Step 9: Test like a skeptic
- Cold-start: delete
~/.ad-room/, run/draftwith zero context. It must produce 20 variations without demanding setup. - Calibration: feed
/gradea deliberately mediocre ad. If it scores above 60, tighten the grading language. - Fuzzy-brand:
/recona brand whose name is a common word. Check the off-brand filter report. - Wrong-key: save a garbage token, confirm
/setupverification fails with a useful message. - Result-type: run
/auditon a lead-gen account and confirm it never mentions purchases or ROAS.
Build order if you're spreading this over a week: Day 1 scaffold plus key manager plus /setup; Day 2 /draft; Day 3 dashboard plus /grade; Days 4 to 5 /recon (the scraper integration is the bulk of the work); Day 6 /map; Day 7 /audit plus testing.
The principles that transfer to any plugin you build
- Skills are markdown, not code. Describe process, rules, and output shape. Claude is the engine; scripts only handle APIs and file output.
- Have a zero-setup entry point. At least one skill must deliver value in two minutes with no keys. That's your trust-builder.
- Keys live in the home directory, never in the plugin. One credentials file, chmod 600, verified live at setup.
- A dashboard makes it a product. The same analysis hits far harder as a branded HTML page that opens itself.
- Be honest about proxies. Run-time isn't spend. Overlap heuristics aren't deduplicated reach. Saying so builds more trust than pretending.
- Put rubrics in reference files. One source of truth, tunable without touching the skill logic.
- Filter fuzzy data and report what you dropped. "Filtered out 61 off-brand ads" is a feature.
Don't want to build it? Where AutoAdy already does this
The build above is genuinely useful, and a weekend well spent. But three of these skills are things AutoAdy already runs as a maintained product, so you can skip the parts you'd otherwise babysit:
- The live account audit is AutoAdy's home turf. Its account health score, drop diagnosis, and fatigue forecast are the same analysis as
/audit, kept current and connected to your real account. Try the free account health grader, drop diagnostic, and fatigue forecast with no build required. - On-brand copy at volume maps to AutoAdy's ad copy generator, variation engine, and hook generator, all of which carry your brand voice automatically.
- Competitor recon and the category map are the honest exception. AutoAdy works on your own connected account; pulling a competitor's Ad Library ads is exactly the DIY job above, and a browser agent or a scraper is the right tool for it. Use the build for the competitor side, and AutoAdy for everything that touches your own account.
Build the plugin for the learning and the competitor intel. Lean on AutoAdy for the parts you'd rather not maintain by hand.