The Claude Skill That Runs My Facebook Ads (With Code)
I built a Claude skill that reads my Meta Ads account via the Graph API, identifies underperformers, rewrites ad copy in my brand voice, and creates new ad sets without me touching Ads Manager. The whole thing is under 300 lines of TypeScript. The ROI was immediate: I cut weekly ads-management time from ~3 hours to about 20 minutes.
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.
Updated June 2026.
TL;DR: I built a Claude skill that reads my Meta Ads account via the Graph API, identifies underperformers, rewrites ad copy in my brand voice, and creates new ad sets without me touching Ads Manager. The whole thing is under 300 lines of TypeScript. The ROI was immediate: I cut weekly ads-management time from ~3 hours to about 20 minutes.
[Operator’s read] I run ads for Pickleland and for my consulting brand. Two accounts, different audiences, constant creative fatigue. I was spending Sunday afternoons in Ads Manager doing things a model should be doing. So I automated it.
Table of contents
Open Table of contents
Why I stopped managing Facebook ads manually
The actual work of running Facebook ads breaks into three jobs:
- Monitoring — checking which ad sets are burning money vs. printing it
- Diagnosing — figuring out why something is underperforming (creative fatigue? bad targeting? landing page?)
- Iterating — writing new copy, creating new ad sets, adjusting budgets
Job 1 is mechanical. Job 3 is mostly mechanical (with a voice constraint). Job 2 needs judgment — and it’s the only one that benefits from a human being in the loop.
A Claude skill can do 1 and 3. I review job 2 outputs before anything ships. That’s the architecture I landed on.
The Meta Graph API setup (this is the annoying part)
Before any code: you need a Meta Business account, a System User, and a permanent access token. Facebook’s dev portal is hostile but the path is:
- Create a Meta App at developers.facebook.com (type: Business)
- Add the Marketing API product
- Under your Business Portfolio → Settings → Users → System Users, create a system user and give it
ADVERTISERrole on your ad account - Generate a token with these permissions:
ads_read,ads_management,business_management
Store the token as META_ACCESS_TOKEN and your ad account ID (format: act_XXXXXXXX) as META_AD_ACCOUNT_ID in your .env.
The skill file structure
.claude/skills/fb-ads/
SKILL.md ← instructions Claude reads
index.ts ← the actual tool implementation
types.ts ← shared typesThe SKILL.md is what tells Claude when and how to use the skill. Mine says:
# Facebook Ads Manager Skill
Use this skill when the user says "check my ads", "run ads report",
"pause underperformers", or "write new ad copy". Never run this
without explicit user instruction — it touches live ad spend.
## What it can do
- Pull performance data for all active ad sets (last 7 or 30 days)
- Flag ad sets with ROAS < 1.5 or CTR < 0.8% as underperformers
- Rewrite ad copy for flagged creatives in Ale's voice
- Create new ad sets with revised copy (PAUSED by default — you approve before activating)
## What it will NOT do
- Change budgets on live ad sets without explicit confirmation
- Activate new ad sets automatically
- Delete anythingThe “never activate automatically” constraint is non-negotiable. This skill creates things in PAUSED state. I review and activate manually. Anything touching live spend needs a human checkpoint.
The core TypeScript code
// .claude/skills/fb-ads/index.ts
import Anthropic from "@anthropic-ai/sdk";
const BASE = "https://graph.facebook.com/v20.0";
const TOKEN = process.env.META_ACCESS_TOKEN!;
const ACCOUNT = process.env.META_AD_ACCOUNT_ID!;
interface AdSetPerformance {
id: string;
name: string;
status: string;
spend: number;
impressions: number;
clicks: number;
conversions: number;
roas: number;
ctr: number;
cpc: number;
}
async function getAdSetPerformance(days = 7): Promise<AdSetPerformance[]> {
const fields = [
"id", "name", "status",
"insights.date_preset(last_" + days + "d){spend,impressions,clicks,actions,action_values}"
].join(",");
const url = `${BASE}/${ACCOUNT}/adsets?fields=${encodeURIComponent(fields)}&access_token=${TOKEN}&limit=100`;
const res = await fetch(url);
const data = await res.json();
return (data.data ?? []).map((adset: any) => {
const ins = adset.insights?.data?.[0] ?? {};
const spend = parseFloat(ins.spend ?? "0");
const impressions = parseInt(ins.impressions ?? "0");
const clicks = parseInt(ins.clicks ?? "0");
const purchaseValue = (ins.action_values ?? [])
.filter((a: any) => a.action_type === "purchase")
.reduce((s: number, a: any) => s + parseFloat(a.value), 0);
const purchases = (ins.actions ?? [])
.filter((a: any) => a.action_type === "purchase")
.reduce((s: number, a: any) => s + parseInt(a.value), 0);
return {
id: adset.id,
name: adset.name,
status: adset.status,
spend,
impressions,
clicks,
conversions: purchases,
roas: spend > 0 ? purchaseValue / spend : 0,
ctr: impressions > 0 ? (clicks / impressions) * 100 : 0,
cpc: clicks > 0 ? spend / clicks : 0,
};
});
}
async function getAdCreatives(adsetId: string): Promise<{ id: string; body: string; title: string }[]> {
const url = `${BASE}/${adsetId}/ads?fields=creative{body,title}&access_token=${TOKEN}`;
const res = await fetch(url);
const data = await res.json();
return (data.data ?? []).map((ad: any) => ({
id: ad.id,
body: ad.creative?.body ?? "",
title: ad.creative?.title ?? "",
}));
}
async function rewriteCopy(original: { body: string; title: string }, context: string): Promise<{ body: string; title: string }> {
const client = new Anthropic();
const msg = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 512,
messages: [{
role: "user",
content: `You are rewriting a Facebook ad in Alejandro Rioja's voice: direct, operator-focused, no hype, results-first. The ad is underperforming. Context: ${context}
Original title: ${original.title}
Original body: ${original.body}
Rewrite it. Keep it under 90 words for the body. Make the headline a specific outcome or number. Return JSON: {"title": "...", "body": "..."}`
}]
});
const text = (msg.content[0] as any).text.replace(/```json\n?/, "").replace(/```/, "").trim();
return JSON.parse(text);
}
export async function runAdsReport(days = 7) {
const adsets = await getAdSetPerformance(days);
const active = adsets.filter(a => a.status === "ACTIVE");
const underperformers = active.filter(a => a.roas < 1.5 || a.ctr < 0.8);
const winners = active.filter(a => a.roas >= 1.5 && a.ctr >= 0.8);
return { adsets: active, underperformers, winners, days };
}
export async function rewriteUnderperformers(report: Awaited<ReturnType<typeof runAdsReport>>) {
const rewrites = [];
for (const adset of report.underperformers) {
const creatives = await getAdCreatives(adset.id);
for (const creative of creatives) {
const context = `ROAS ${adset.roas.toFixed(2)}, CTR ${adset.ctr.toFixed(2)}%, spend $${adset.spend.toFixed(0)} over ${report.days} days`;
const newCopy = await rewriteCopy(creative, context);
rewrites.push({ adsetId: adset.id, adsetName: adset.name, original: creative, rewritten: newCopy });
}
}
return rewrites;
}How I use it day-to-day
The skill is invoked from Claude Code (my daily driver). A typical Monday morning session:
> check my ads from the last 7 daysClaude runs runAdsReport(7), formats the results as a table, flags underperformers, and asks if I want rewrites. I say yes. It generates new copy, shows me both versions side by side, and creates PAUSED ad sets with the new creative. I review them in Ads Manager, activate the ones I like, and archive the losers.
Total time: 20 minutes. Zero Sunday afternoons in Ads Manager.
What this doesn’t replace
The skill can’t tell me whether a product-market fit problem is masquerading as a copy problem. If ROAS is bad across the board, that’s a funnel or offer issue, not a headline issue. Claude will faithfully rewrite copy on a broken funnel — and the rewrites won’t save it.
The diagnostic step is still mine. I read the report, look at the funnel data, and decide whether we’re iterating creative or solving something upstream. The agent is fast at everything except that judgment call.
Related: this skill is Facebook-only and reads from my own ad account. For read-only reporting across GA4, Search Console, Bing, Ahrefs, and Core Web Vitals in one Claude connector, I run SiteWide MCP — same idea of pointing Claude at real marketing data instead of a dashboard, wider surface.
The operator’s bottom line
If you’re running ads manually and touching Ads Manager more than twice a week, you’re doing ops that a script should do. The Graph API is well-documented and the Meta permissions flow, while annoying, is a one-time setup. Build the skill in an afternoon. The payback in reclaimed time shows up in week one.
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
When NOT to Build an AI Agent (Do This Instead)
The five signals that mean an AI agent is the wrong tool, and the ladder of simpler alternatives I try first — before I write a line of agent code.
AI AgentsAI Agent Pricing: What to Charge Clients
How I price AI agent builds for clients — a build fee plus a maintenance retainer, how to size each one, and the contract terms that stop scope creep.
AI AgentsClaude Skills vs. Slash Commands vs. Subagents
Skills, slash commands, and subagents solve different problems in Claude. Here is the decision framework I use to pick the right one for each job.
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.