I Built a Claude Skill That Runs My Facebook Ads — Here's the 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.
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.
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.
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.