
Meta Ads
- 58 installs
- 262 repo stars
- Updated July 11, 2026
- hoodini/ai-agents-skills
Pull, analyze, and manage Meta ads (Facebook, Instagram, Messenger, Click-to-WhatsApp, Threads) via the Marketing API, including creating and pausing campaigns.
About
Uses the Meta Marketing API to report ad performance and, with confirmation, write changes like pausing, budget edits, duplication, and full campaign creation. A developer or marketer uses it to analyze or manage Meta ad campaigns.
- auth_check/list_accounts/list_campaigns discovery flow
- Read-safe insights vs guarded write actions; 37-month data wall
Meta Ads by the numbers
- 58 all-time installs (skills.sh)
- +6 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #541 of 853 Sales & Marketing skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hoodini/ai-agents-skills --skill meta-adsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 262 |
| Last updated | July 11, 2026 |
| Repository | hoodini/ai-agents-skills ↗ |
What it does
Pull, analyze, and manage Meta ads (Facebook, Instagram, Messenger, Click-to-WhatsApp, Threads) via the Marketing API, including creating and pausing campaigns.
Files
Meta Ads (Marketing API)
Pull ad performance data from Meta (Facebook, Instagram, Messenger, Click-to-WhatsApp, Threads), run analyses, and — with explicit confirmation — write changes back (pause, budget change, duplicate, full campaign creation with images and copy).
When to consult this skill
Any question about Meta ad performance, creative health, audience/placement mix, or campaign management. If the user asks "how are my ads doing" without specifying a platform, ask whether they mean Meta or Google before defaulting.
Three things to internalize before touching any script
1. Where the scripts run matters. The scripts call graph.facebook.com over the open internet. Some execution environments (including Claude's Linux sandbox in certain configurations) block this endpoint via proxy. If you hit a ProxyError / Tunnel connection failed: 403 on the first call, run the scripts from the user's host machine — macOS, Windows, or Linux — using that machine's Python. Per-OS commands:
- macOS:
python3 scripts/auth_check.py, deps viapython3 -m pip install --user requests - Windows:
python scripts/auth_check.py(orpy scripts/auth_check.pyifpythonisn't on PATH), deps viapython -m pip install --user requests - Linux:
python3 scripts/auth_check.py, deps viapython3 -m pip install --user requests(add--break-system-packageson Debian/Ubuntu 22+)
The scripts themselves are pure Python with one dependency (requests) and no shell/OS assumptions, so they run identically everywhere once dependencies are installed.
2. WhatsApp is not a separate ad surface. Click-to-WhatsApp ads run on Facebook and Instagram placements. They show up in the Marketing API as regular ads with destination_type=WHATSAPP and conversion events under actions (look for onsite_conversion.messaging_conversation_started_7d and similar). Don't promise the user a "WhatsApp ads dashboard" — there isn't one.
3. Read is safe, write is not. Insights endpoints (GET) are idempotent and harmless. Pause / budget / duplicate (POST) actions touch real money. The write rules in references/write-actions.md are non-negotiable — read that file before any write call.
The 37-month data wall
Meta's insights API only serves data from the last 37 months. Any campaign older than that returns error 3018 and is effectively invisible. If the user asks "what was my best campaign ever" and their activity predates the window, the answer is "the API can't tell you — check Ads Manager's archived reports UI." Surface this before starting a query that's going to fail.
Before anything else: check setup + discover accounts
The first three calls are always the same:
1. python scripts/auth_check.py — is the token alive, what identity does it resolve to, how many ad accounts does it see? 2. python scripts/list_accounts.py --with-recent-spend — which accounts are actually running ads right now? (Sorted by last-30-day spend, then lifetime spend.) This tells you which account to pin as META_AD_ACCOUNT_ID. 3. python scripts/list_campaigns.py — once you have the right account, what campaigns are on it?
If any of these fail, stop and walk the user through references/setup.md. Don't try to be clever and guess credentials.
How to guide a user through setup (first-timers)
references/setup.md is written as a click-by-click walkthrough. When a user has no credentials yet:
1. One step at a time. Don't dump the whole document. Ask them which OS they're on, then give them the prerequisites block for that OS. Wait for them to report "done" before moving on. 2. Confirm the path choice. Walk through the Step 0 decision questions with them in chat. Don't assume — especially "do you boost from Instagram?" is easy to misjudge. 3. Read back what they paste. When they share a token, App ID, or ad account ID, echo it back (redact tokens to the first 8 chars) and confirm you have it right before you move on. This catches the #1 setup bug: pasting the System User's ID where the Ad Account ID should go. 4. Verify before moving on. After they fill in .env, run auth_check.py immediately. Don't let them run analysis commands until the verification returns ok: true — every "the report is empty" issue traces back to a half-broken .env. 5. Match their OS in every command. If they said "I'm on Windows," don't tell them to run python3 — tell them python or py. If they said "Mac", use python3. The setup.md has per-OS blocks; copy the matching one into chat.
Required env vars (user provides these once during setup):
META_ACCESS_TOKEN— either a long-lived user token (Path B, 60 days) or System User token (Path A, never expires). See the decision tree below.META_AD_ACCOUNT_ID— default ad account, formatact_1234567890(scripts accept--account-idto override per call).META_API_VERSION— defaults tov25.0(current as of early 2026; v26.0 expected ~Sep 2026).
The user puts these in a .env file at the working directory or exports them in the shell. Scripts auto-load .env if present. See assets/env.template.
Path A vs Path B: which token type?
This is the single most common failure mode. Get it right up front.
Use Path A (System User token) when:
- Ads run inside a Meta Business Manager you control.
- The ad accounts you want to analyze are owned by that BM.
- You want a token that never expires.
Use Path B (long-lived user token) when:
- You boost posts from the Instagram app (these often create a personal ad account outside any BM — invisible to System User tokens no matter what permissions you grant).
- Your ad account is attached to your personal Facebook profile, not a BM.
- You want to see every ad account your personal FB login can access (typically the broadest view).
Can't tell which? Start with Path B. It sees a strict superset of what Path A sees. Once you've identified the important accounts, you can optionally move them into a BM and switch to Path A for permanence.
Full setup steps for both paths: references/setup.md.
Workflow: read operations
For any read request, follow this loop:
1. Pick the right script. Don't reinvent. The bundled scripts cover the common cases:
auth_check.py— verify token, list visible accounts brieflylist_accounts.py— discover all ad accounts, decode status, show lifetime + optional recent spendlist_campaigns.py— campaigns under an account, with status filtersfetch_insights.py— the workhorse. Date ranges, breakdowns (publisher_platform, age, gender, country, placement, device_platform), level (account/campaign/adset/ad), custom field lists. Read its--helpbefore calling.creative_fatigue.py— frequency + CTR decay analysis at the ad levelanomaly_detect.py— compares a date range against the prior equivalent range, flags significant changesexchange_token.py— Path B only: swap short-lived user token for 60-day long-lived token
Write scripts (pause/budget/duplicate/create — read write-actions.md first):
pause_ad.py— pause/resume a single campaign, ad set, or adupdate_budget.py— modify daily or lifetime budget with 2×-per-call safety capduplicate_ad.py— clone an ad, ad set, or entire campaign into a new copycreate_campaign.py— build a whole campaign (campaign + ad sets + creatives + ads) from a spec JSON. Handles image upload and interest resolution. Seereferences/campaign-creation.md.rollback_creation.py— pause or delete every object from acreate_campaign.pyrun using its state file
2. Run it, capture JSON output. Every script outputs structured JSON to stdout. Don't try to parse human-readable text — there isn't any. Pipe to a file if the user wants to keep the raw data: python scripts/fetch_insights.py ... > insights.json.
3. Analyze with the playbooks. Once you have the data, consult references/analysis-playbooks.md for the relevant pattern (fatigue, funnel, audience mix, anomalies). The playbook tells you what thresholds matter and what to recommend.
4. Present in the user's preferred language. If the user writes in Hebrew, respond in Hebrew. Keep metric names in English (CTR, CPA, ROAS) so they cross-reference cleanly with Ads Manager. If asked for a doc/dashboard, follow whatever design system skill they've set up.
Workflow: write operations (pause, budget, duplicate)
These are the dangerous ones. Follow this protocol every time, no shortcuts:
1. Surface the action and the impact in plain language before calling. Example: "Pause ad Campaign_v3 (ID 120214...). This ad spent ₪47 in the last 7 days with 0.8% CTR. Confirm?" 2. Wait for explicit `yes` / `confirm` / the user's language equivalent in chat. A previous "go ahead" in the conversation does not carry over. Each write needs its own confirmation. 3. One action at a time by default. If the user wants to bulk-pause 12 ads, present a numbered list and confirm the whole batch in one explicit message ("pause all 12") — but log each call separately. 4. Always do a dry-run first when the script supports it (--dry-run flag prints what would change without calling the API). 5. Never auto-shift budgets above +50% in one call. If the user wants a 3x increase, do it in steps with confirmation each time. Meta's learning phase resets on big budget changes anyway.
Full rules and edge cases in references/write-actions.md. Read it before calling any of: pause_ad.py, update_budget.py, duplicate_ad.py, create_campaign.py.
Workflow: creating a campaign from scratch
Use create_campaign.py to build a full campaign tree (campaign → ad sets → creatives → ads) in one shot from a spec JSON. Good for A/B flights where you want the exact same structure with disciplined naming and auditable creation history.
Before you touch the script:
1. Check if the user has a pixel. No pixel → force objective: OUTCOME_TRAFFIC and optimization_goal: LINK_CLICKS. Don't build a conversions campaign against an account that can't measure conversions; you'd be paying Meta to optimize toward an event it never sees. Surface this to the user and let them decide whether to wait for pixel install or launch blind on traffic. 2. Check the account balance. Status code 3 (UNSETTLED) on the ad account means objects can be created but nothing will deliver until balance clears. Don't block creation, but tell the user before they expect spend. 3. Discover the right page + Instagram identity. Users often own multiple pages. Query /me/accounts and /act_.../promote_pages and match against which page ran their historical winning ads (fetch a handful of recent ads, pull creative.effective_object_story_id to see the page ID in the prefix). 4. Default everything to PAUSED. The campaign, ad sets, and ads should all be PAUSED at creation. The user flips ACTIVE themselves in Ads Manager once they've eyeballed the creative. This is the last safety net. 5. Dry-run first, always. create_campaign.py --spec foo.json --dry-run prints the full plan with resolved interest IDs, currency-corrected budgets, and targeting summary. Walk the dry-run back to the user in plain language before asking for --confirm.
Per write-actions.md, a fresh explicit confirmation is required for --confirm even if the user approved the plan earlier in the same session. That confirmation must name the action (e.g. "confirm create", "בצע יצירה") — not a generic "ok".
Full spec format, field reference, currency handling, interest resolution, state file and rollback details: references/campaign-creation.md.
When NOT to use create_campaign.py
- Single ad addition to existing ad set — just call the ads endpoint directly or use the UI. Overkill to spec-and-run for one ad.
- Duplicating a winner —
duplicate_ad.pyis faster and preserves learning signals. - Dynamic creative, catalog ads, collection ads — not supported in v1. The script only handles single-image link ads via
link_data. Extend it or build a sibling script if needed.
Gotchas the scripts already handle for you
- Currency minor units.
amount_spent,balance,spend_cap,daily_budget,lifetime_budgetare returned by Meta as strings of minor units (agorot/cents/pence).list_accounts.pyandlist_campaigns.pyconvert these to major units in a_majoror plain decimal field. Zero-decimal currencies (JPY, KRW, VND, ISK, TWD, etc.) are not divided. - Account status codes. Scripts decode 1→ACTIVE, 2→DISABLED, 3→UNSETTLED, 7→PENDING_RISK_REVIEW, etc.
- Pagination. All scripts auto-paginate via
meta_client.paginate(). - Rate limits.
meta_client._request()backs off on subcodes 1487742 / 2446079 / 1487390 and retries. - Async insights.
fetch_insights.pyauto-falls-back to async jobs when sync queries would time out (or you can force with--async). - DELETED campaigns. Meta's campaigns endpoint returns error 1815001 when you request DELETED. The script's
--statuschoices deliberately exclude DELETED. To see deleted campaigns, use Ads Manager UI.
What the bundled scripts don't handle
- Initial auth / token generation (manual, see
references/setup.md). - Pixel / Conversions API event ingestion (separate API).
- Dynamic creative / catalog / collection ad formats (only single-image link ads).
- Custom Audience creation (you can reference existing audiences, but not build new ones from here).
- Editing an already-live creative in place (you create new creatives via
create_campaign.py; editing an existing one is a different API shape).
If the user needs something the scripts don't cover, extend the skill in a writeable copy and re-package — see references/campaign-creation.md for the pattern create_campaign.py establishes for write scripts. Don't fork separate one-off scripts in the user's working directory; consolidate into the skill so the next run benefits.
Reference files
Read these on demand, not all upfront.
references/setup.md— One-time setup: decision tree Path A vs Path B, how to create the app, get tokens, find account IDs. Read this when the user has no credentials, or whenauth_check.pyfails.references/insights-fields.md— Field glossary, breakdown options, common metric definitions, gotchas (attribution windows, action_attribution, deduplication). Read this when constructing a custom insights query.references/analysis-playbooks.md— Patterns for creative fatigue, audience analysis, funnel diagnosis, anomaly response. Read this when the user asks for analysis, not just data.references/write-actions.md— Mandatory before any write call. Confirmation flow, safety thresholds, rollback patterns — including the campaign-creation section before usingcreate_campaign.py.references/campaign-creation.md— Spec format, field reference, currency handling, interest resolution, state file and rollback details forcreate_campaign.py. Read this when the user asks to launch a campaign, A/B test, or new ad flight from scratch.references/troubleshooting.md— Common failure modes (sandbox proxy, IG boost invisible to SU tokens, missing requests module, encoding issues, 37-month cap, etc.). Read this when any script returnsok: falseor something unexpected.
Scaling beyond personal use
This skill is built for one user, one set of credentials, a handful of ad accounts. Multi-tenant / client-agency use (one operator managing dozens of client BMs) is a different build:
- Per-client credential storage (not everyone's tokens in one .env).
- Each client issues their own System User token from their own BM.
- Audit log of every write action, who triggered it, against which account.
Don't try to retrofit the personal skill for multi-tenant use. Tell the user it's a different build.
# Meta Ads API credentials
# Copy this file to `.env` in the directory where you'll run the skill,
# then fill in the values from references/setup.md.
#
# DO NOT commit this file with real values. Add `.env` to .gitignore.
# --- Required ---
# Access token. Either:
# - A System User token (Path A, never expires) — only sees ad accounts
# owned by its Business Manager.
# - A long-lived user token (Path B, 60 days) — sees every ad account
# the underlying FB user can see, including personal ad accounts not
# in any BM. Required if you boost Instagram posts from the IG app.
# See references/setup.md for the decision tree and how to generate each.
META_ACCESS_TOKEN=
# Default ad account in the format `act_<digits>`. Find candidates by running
# `python scripts/list_accounts.py --with-recent-spend`.
# Scripts accept --account-id to override this per call when you have multiple.
META_AD_ACCOUNT_ID=act_
# --- Optional but recommended ---
# Meta Marketing API version. v25.0 is current as of early 2026.
# v26.0 expected ~Sep 2026; bump this when it ships.
# Versions v22.0 and earlier are deprecated and will hard-fail.
META_API_VERSION=v25.0
# --- Only needed for Path B token refresh ---
# If you use a user token (Path B), these let scripts/exchange_token.py
# swap a short-lived token for a 60-day long-lived one. Find them in your
# Meta developer app → Settings → Basic.
# Not needed for System User tokens (Path A).
META_APP_ID=
META_APP_SECRET=
Analysis Playbooks
Read this when the user wants analysis, not just data. Each playbook is a recipe: what to query, how to interpret it, what to recommend. Don't just dump numbers — synthesize.
Universal rule: always ground recommendations in the user's actual numbers
Generic advice is worthless. Don't say "consider refreshing your creative" — say "ad Summer_Launch_v3 has frequency 4.2 and CTR fell 38% in the second half of last 28d while CPM rose 22%. Pause it; the duplicate Summer_Launch_v4 is already taking over delivery." Pull names, IDs, and exact numbers from the script outputs.
Playbook 1: Time-range performance report
Trigger: "How are my ads doing this week?", "Give me a 30-day report", "What's my ROAS lately?"
Query:
python scripts/fetch_insights.py --level campaign --date-preset last_30d
python scripts/fetch_insights.py --level account --date-preset last_30d
python scripts/fetch_insights.py --level account --date-preset last_30d --time-increment 1Interpret:
- Account-level totals → overall health
- Campaign-level rows → where the money went, sorted by spend
- Daily time series → trend (climbing, steady, or declining)
Recommend:
- If ROAS dropped vs. last period → run
anomaly_detect.pyto localize - If one campaign eats 80% of budget but has worse ROAS than others → suggest reallocating
- If CPM climbed steadily → audience saturation or auction competition; suggest creative refresh + audience expansion
Output format: A clean prose summary with 3–5 numbered insights. Append a small table with per-campaign spend / ROAS / CPA for quick scanning. Match the user's working language, but keep metric names in English (CTR, CPA, ROAS, CPM) so they cross-reference cleanly with Ads Manager.
Playbook 2: Creative fatigue diagnosis
Trigger: "Is my ad fatigued?", "Why is CTR dropping?", "Should I refresh creative?"
Query:
python scripts/creative_fatigue.py --date-preset last_28dInterpret the script's output:
fatigued_ads[]is sorted by severity. Top of list = most urgent.- A single flag (frequency only, or CTR decay only) is mild.
- Two flags (frequency + CTR decay) is real fatigue.
- Three flags (frequency + CTR decay + CPM rising) is severe — Meta is now actively penalizing delivery.
Recommend per ad:
- Mild fatigue, low spend: monitor; don't act
- Real fatigue, ad still profitable: duplicate the ad (
duplicate_ad.py --type ad), then pause the original after the duplicate enters learning. This buys you a fresh learning phase without losing the working setup. - Severe fatigue: pause immediately. Meta's penalty (rising CPM) means you're paying more for less. Replace with new creative, not a duplicate.
- Whole audience saturated (multiple ads in the same ad set all fatigued): the audience is the problem, not the creative. Expand targeting or move to a lookalike.
Playbook 3: Audience / placement breakdown
Trigger: "Where are my ads working?", "Which platform performs best?", "Should I split my budget by placement?"
Query:
# Platform breakdown
python scripts/fetch_insights.py --level campaign --date-preset last_30d \
--breakdowns publisher_platform
# Placement detail (where on each platform)
python scripts/fetch_insights.py --level campaign --date-preset last_30d \
--breakdowns publisher_platform platform_position
# Demographic
python scripts/fetch_insights.py --level campaign --date-preset last_30d \
--breakdowns age gender
# Geographic
python scripts/fetch_insights.py --level campaign --date-preset last_30d \
--breakdowns countryInterpret:
- Compute CPA or ROAS per breakdown row. Don't just look at CTR — high CTR can hide bad conversion rates downstream.
- Identify cells with sufficient volume to trust (≥30 conversions, or ≥$50 spend — fewer than that and you're staring at noise).
- Compare CPA across cells. A 2x difference in CPA between Instagram Reels and Facebook Feed is meaningful; a 20% difference probably isn't.
Recommend:
- If one placement has CPA <50% of the worst → consider isolating it in a new campaign with full budget
- If a demographic segment massively outperforms → tighten targeting (but warn: Meta's algorithm is usually better at finding people than we are at telling it where to look)
- For CTWA campaigns: Instagram Stories often dominates because it's a low-friction tap-to-message UI. If FB Feed is winning instead, that's unusual and worth investigating
Playbook 4: Conversion funnel analysis (with CTWA support)
Trigger: "Where are people dropping off?", "Why aren't my course ads converting?", "Are people clicking but not buying?"
Query for standard web conversion funnel (landing page → purchase / sign-up):
python scripts/fetch_insights.py --level campaign --date-preset last_30d \
--fields spend impressions actions action_values cost_per_action_typeThen in the response, count action types:
link_click— they clickedlanding_page_view— Pixel confirms page loaded (drop here = slow page or misconfigured Pixel)view_content— they engaged with contentadd_to_cart/initiate_checkout— moved toward purchase (if e-commerce)purchase/complete_registration— converted
Compute drop-off ratios:
- LPV / link_clicks → site speed / pixel issues if low (<70%)
- view_content / LPV → page quality
- purchase / view_content → offer / pricing / trust
For CTWA funnel:
link_click(or omit — Meta uses different signals) →onsite_conversion.messaging_conversation_started_7d→onsite_conversion.messaging_first_reply→ eventual sale (tracked outside Meta)
Recommend:
- Drop-off between click and LPV → landing page issue (speed, Pixel firing, broken link)
- Drop-off between LPV and view_content → page doesn't deliver on the ad's promise
- Drop-off between view_content and purchase → offer/pricing problem, not an ad problem. The skill should say so directly: "Your ads are working. The conversion problem is on your landing page."
Playbook 5: Anomaly response
Trigger: "Something's off", "My CPA spiked", "Why did spend drop?", "Did something break overnight?"
Query:
python scripts/anomaly_detect.py --window-days 7For shorter horizons:
python scripts/anomaly_detect.py --window-days 3 --pct-threshold 0.20Interpret the output: The script returns a sorted list of campaigns where metrics moved significantly. Look at:
statusfield:stopped_spending,started_spending, oractive_changeanomaliesarray: which metric moved and by how much
Common patterns and what they mean:
| Pattern | Likely cause | Action |
|---|---|---|
| Spend dropped, impressions dropped, CTR steady | Budget exhausted, daily cap hit, or campaign paused | Check status; check budget vs. spend |
| CPM spiked, CTR steady | Auction competition (often around holidays, sales events, elections) | Wait or raise bid; not a creative issue |
| CTR dropped, CPM steady | Creative fatigue or audience burnout | Run fatigue playbook |
| Spend up, impressions up, ROAS down | Algorithm explored new audiences, found worse converters | Consider tightening targeting if it persists >5 days |
| Conversions dropped to zero, everything else normal | Pixel broken or website broken | Check Pixel via Events Manager immediately |
| Spend zero on a previously active campaign | Account issue (billing, policy, ad disapproval) | Check account status and ad-level disapproval reasons |
Recommend cautiously: anomalies have lots of false positives. Don't pause a campaign because one day looked weird — wait 2–3 days unless the issue is clearly catastrophic (like zero conversions for a converting campaign).
Playbook 6: Click-to-WhatsApp specific analysis
Trigger: "How are my CTWA ads doing?", "Cost per WhatsApp conversation?", "Should I run more CTWA?"
Why this needs its own playbook: CTWA campaigns optimize for messaging_conversations_started, not website conversions. The funnel is different (ad → tap → WhatsApp opens → first business message → reply → eventual sale). Analyzing them with web-conversion frameworks gives garbage answers.
Query:
python scripts/fetch_insights.py --level adset --date-preset last_30d \
--fields spend impressions actions cost_per_action_type \
--filtering '[{"field":"adset.optimization_goal","operator":"EQUAL","value":"CONVERSATIONS"}]'Key metrics:
- Cost per conversation started (
cost_per_action_typewhere action_type =onsite_conversion.messaging_conversation_started_7d) - Reply rate:
messaging_first_reply/messaging_conversation_started_7d— high reply rate means real intent; low means accidental taps or low-quality leads - Cost per reply (cost per conversation × inverse reply rate) — the "real" cost of a usable lead
Recommend:
- Compare cost per conversation across creatives and audiences — wide spread (>2x) means easy optimization
- Low reply rate (<40%) suggests the ad creative oversells; refine the hook
- If reply rate is good but no sales materialize, the bottleneck is your WhatsApp follow-up sequence, not the ads
Cross-playbook: when to escalate
If the user wants ongoing monitoring (daily anomaly checks, weekly reports without asking), tell them this skill is on-demand only — scheduled runs belong in a cron job, a scheduled-tasks integration, or whatever automation layer the user already has. Don't try to fake persistence inside the skill.
If a third party (an agency client, a different team's account) wants this type of analysis, tell the user this skill assumes single-tenant credentials and the multi-tenant version is a different build. Don't run other people's analyses with personal credentials — each owner should set up their own app and token.
Creating campaigns from scratch
scripts/create_campaign.py builds an entire campaign tree (campaign → ad sets → creatives → ads) in one call from a spec JSON. This is a write operation — read write-actions.md first; the "Campaign creation" section at the bottom of that file is mandatory before you call --confirm for the first time in a session.
The workflow
1. Write a spec JSON that describes the campaign you want. 2. Run --dry-run. This reads the spec, queries Meta for account currency, resolves interest names to IDs, and prints the plan — no writes. 3. Walk the plan back to the user. They name the change to confirm. 4. Run --confirm. The script creates everything PAUSED by default and writes a state JSON with every ID. 5. If the user wants to activate: they toggle ACTIVE in Ads Manager (or via the API). Never flip ACTIVE programmatically as part of creation — that removes the final checkpoint.
Spec format (minimum viable)
{
"campaign_name": "My Campaign - Q2 Flight",
"objective": "OUTCOME_TRAFFIC",
"status": "PAUSED",
"special_ad_categories": [],
"identity": {
"page_id": "1234567890",
"instagram_user_id": "17841400000000000"
},
"landing_url": "https://example.com",
"ad_sets": [
{
"name": "Angle A — Broad",
"daily_budget": 50.00,
"optimization_goal": "LINK_CLICKS",
"billing_event": "LINK_CLICKS",
"targeting": {
"countries": ["IL"],
"age_min": 25,
"age_max": 45,
"interests": ["Artificial intelligence", "Productivity"],
"publisher_platforms": ["instagram"],
"instagram_positions": ["stream", "story", "reels"]
},
"image_path": "./creative/angle_a.png",
"ads": [
{
"name": "A1_Direct",
"message": "Primary body copy — can be multi-line.",
"headline": "Short punchy headline",
"description": "Optional secondary line",
"cta": "LEARN_MORE"
}
]
}
]
}Field reference
Top level
| Field | Required | Notes |
|---|---|---|
campaign_name | yes | Shows up in Ads Manager exactly as typed. Put identifiers (e.g. test name, date) in here for sanity later. |
objective | yes | Meta's modern objectives: OUTCOME_TRAFFIC, OUTCOME_AWARENESS, OUTCOME_ENGAGEMENT, OUTCOME_LEADS, OUTCOME_SALES, OUTCOME_APP_PROMOTION. For courses/products without a pixel, use OUTCOME_TRAFFIC. For courses/products with a pixel firing Purchase, use OUTCOME_SALES. |
status | no | Default PAUSED. Only override with ACTIVE if you're absolutely sure — you lose the review step. |
special_ad_categories | no | Default []. Required ["CREDIT"], ["EMPLOYMENT"], ["HOUSING"], ["ISSUES_ELECTIONS_POLITICS"], or ["FINANCIAL_PRODUCTS_SERVICES"] for those regulated verticals. Wrong category here can get the whole campaign rejected. |
buying_type | no | Default AUCTION. RESERVED is for Reach & Frequency (needs account eligibility). |
identity.page_id | yes | The Facebook Page running the ads. Discover via GET /me/accounts or GET /act_.../promote_pages. |
identity.instagram_user_id | no | The IG account running the ads (discover via the page's instagram_business_account field). Required if you want ads to show with your IG handle instead of a generic "Sponsored by Page Name". |
landing_url | yes for traffic | Where clicks go. |
Ad set level
| Field | Required | Notes |
|---|---|---|
name | yes | Descriptive. Good habits: include angle + audience descriptor, e.g. "Angle 1 — Broad IL 25-45 AI". |
daily_budget | one of | Major units in account currency (50.00 for ₪50). Script queries the currency, converts to minor units (agorot/cents). |
lifetime_budget + end_time | one of | Use when you want a total spend cap instead of daily pacing. end_time is ISO 8601. |
billing_event | no | Default LINK_CLICKS. IMPRESSIONS if optimizing for reach. |
optimization_goal | no | Default LINK_CLICKS. Common: LANDING_PAGE_VIEWS, OFFSITE_CONVERSIONS (needs pixel), REACH, IMPRESSIONS. |
bid_strategy | no | Default LOWEST_COST_WITHOUT_CAP. Use COST_CAP with bid_amount when you want to hold a CPA ceiling. |
bid_amount | conditional | Major units. Required if bid_strategy=COST_CAP or LOWEST_COST_WITH_BID_CAP. |
status | no | Default PAUSED. |
targeting.countries | yes | ISO-2 list, e.g. ["IL"]. Or use full geo_locations dict for regions/cities. |
targeting.age_min / age_max | no | Defaults to Meta's (18-65). Narrow deliberately. |
targeting.genders | no | [1] male, [2] female, omit for all. |
targeting.interests | no | List of names — script resolves to IDs via /search?type=adinterest. Names with no match are dropped and warned. For stability across runs, use interest_ids instead. |
targeting.interest_ids | no | Pre-resolved numeric IDs. Bypasses the search lookup. |
targeting.publisher_platforms | no | Default ["instagram"]. Add "facebook", "messenger", "audience_network". |
targeting.instagram_positions | no | Default ["stream", "story", "reels"]. |
targeting.facebook_positions | no | Default ["feed", "story", "video_feeds"] when facebook is in publisher_platforms. |
targeting.advantage_audience | no | Default true. Allows Meta to expand beyond your interests when it finds high-intent users outside them. Turn off with false for strict targeting tests. |
targeting.custom_audiences | no | List of {"id": "..."}. |
targeting.excluded_custom_audiences | no | Same shape. Useful for excluding existing customers. |
image_path | one of | Path to image file, uploaded to /act_.../adimages once per ad set. Recommended: 4:5 or 1:1, PNG, under 4 MB. |
image_hash | one of | Pre-uploaded image hash (skip upload). |
Ad level
Each ad inside an ad set uses the ad set's image.
| Field | Required | Notes |
|---|---|---|
name | yes | Ad is named Ad_<name> and its creative Creative_<name>. Keep short and unique within the campaign. |
message | yes | Primary body text. Multi-line allowed. No length limit enforced by the API, but Meta truncates in-feed around 125 characters before "See More". |
headline | yes | Bold headline below the image. Keep under 40 characters for mobile. |
description | no | Small gray line below headline. Often cut by placements. |
cta | no | Default LEARN_MORE. Others: SIGN_UP, SHOP_NOW, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, CONTACT_US, APPLY_NOW, WATCH_MORE, INSTALL_MOBILE_APP, USE_APP, MESSAGE_PAGE, WHATSAPP_MESSAGE, NO_BUTTON. |
standard_enhancements | no | Default false (opts out). Meta's auto-enhancements alter the creative — brightness, cropping, text overlays. Leave opt-out if you care about pixel-for-pixel fidelity. |
status | no | Default PAUSED. |
Currency handling
The script queries /act_.../?fields=currency once at the start and converts daily_budget / lifetime_budget / bid_amount from major units to whatever minor unit the account uses:
- ILS / USD / EUR / GBP etc. → × 100 (agorot, cents, pence)
- JPY / KRW / VND / ISK / TWD / XAF / XOF / CLP → × 1 (no minor unit)
Write budgets in major units only. Never try to pre-convert.
Interest resolution
targeting.interests names are resolved via Meta's /search?type=adinterest endpoint. The script takes the top-ranked hit per name. This is convenient for readable specs but has two caveats:
1. The search ranking can change. A spec that resolved to "Productivity" today might resolve to "Productivity (software category)" tomorrow. 2. Search can return no match. Those names are dropped with a [warn] log.
For specs you'll re-run months later and want identical targeting, do one dry-run, copy the resolved_interests IDs into interest_ids, and delete interests. Now the targeting is frozen.
State file and rollback
Every --confirm run writes <spec>_state_<timestamp>.json with every object ID:
{
"ok": true,
"campaign_id": "120...",
"ad_sets": [
{"adset_id": "120...", "image_hash": "...", "ads": [{"ad_id": "120...", "creative_id": "120..."}]}
],
"objects": [{"type": "campaign", "id": "120...", "name": "..."}, ...]
}scripts/rollback_creation.py --state <file> --pause pauses everything. --delete permanently deletes in reverse order (ads → creatives are orphaned → adsets → campaign). Prefer --pause unless you really want the objects gone from Ads Manager history.
Common errors and how to handle them
`error_subcode 1885036` — Ad account is unsettled. The campaign objects get created fine but nothing will deliver until the account balance is paid. Don't block on this at creation time; surface it to the user and continue.
`The ad creative spec is invalid`. Usually missing page_id or a malformed call_to_action value. Double-check the cta is in VALID_CTAS and the landing URL is fully qualified (https://...).
`Invalid parameter` on `flexible_spec`. Means an interest ID is stale. Re-run the dry-run so search re-resolves names, or remove the offending interest.
`special_ad_categories` mismatch. If your ad is in a regulated vertical and you forgot to mark it, Meta rejects the whole ad set. Reviewable in the ad set's delivery_info in Ads Manager.
Account not admin of page. The ad account owner must have advertiser-or-higher role on the Facebook Page. Fix in Business Settings → Pages → assign.
Instagram identity missing. If instagram_user_id is omitted, ads still run on Instagram placements but show "Sponsored by <Page Name>" instead of your IG handle. Almost always you want to set it.
When to NOT use this script
- You only want to duplicate an existing ad. Use
scripts/duplicate_ad.py— it copies targeting, creative, and settings in one shot and is one API call instead of six. - You're iterating on creative for an existing ad set. Creating a new campaign per iteration pollutes Ads Manager. Add new ads to the existing ad set via a partial spec or do it in the UI.
- You want dynamic creative / catalog ads / collection ads. Those have different creative shapes (
asset_feed_spec,product_set_id,template_data). This script only handles single-image link ads. Extend it or build a sibling script.
Insights Fields, Breakdowns & Gotchas
Read this when constructing a custom insights query (anything beyond the defaults in fetch_insights.py). It's a reference, not a tutorial — skim for what you need.
Standard fields you'll use most
| Field | What it means | Notes |
|---|---|---|
spend | Money spent in the account currency (major units, e.g., 12.34) | Already in major units, unlike budget fields elsewhere. |
impressions | Times the ad was rendered | Includes auto-play video impressions. |
reach | Unique people reached | Not always returned at the ad level for cross-placement deduplication reasons. |
frequency | impressions / reach | Above ~3 is the rough fatigue zone. |
clicks | All clicks (including profile clicks, like clicks). | Use link_clicks from actions array for outbound clicks only. |
ctr | clicks / impressions, as a percentage. | Same caveat — this is "all clicks" CTR. For link CTR, divide actions[link_click] by impressions. |
cpc | spend / clicks | All-clicks CPC. |
cpm | (spend / impressions) × 1000 | Cost per 1000 impressions. |
cpp | spend / reach × 1000 | Cost per 1000 unique reach. |
purchase_roas | Purchase value / spend | Returned as an array of objects, one per attribution scope. See below. |
actions | All conversion events for this row | Big array, see below. |
action_values | Monetary value of those events (where applicable) | Parallel to actions. |
cost_per_action_type | Cost per event, broken out by action type | Useful for CPA-by-event-type. |
The actions array — the most important and most confusing field
actions is a list like:
[
{"action_type": "link_click", "value": "234"},
{"action_type": "post_engagement", "value": "1240"},
{"action_type": "onsite_conversion.messaging_conversation_started_7d", "value": "12"},
{"action_type": "purchase", "value": "8"},
{"action_type": "omni_purchase", "value": "8"}
]Common action_type values you'll grep for:
link_click— outbound link clicks (the meaningful CTR numerator for traffic objectives)post_engagement— likes/comments/shares/clicks combinedlanding_page_view— pixel fired LPV (more reliable signal than link_click, requires Pixel)purchase— conversion via Pixel/CAPIomni_purchase— purchase across all surfaces (web + app + offline) — usually what you wantlead— lead form submissioncomplete_registration— sign-up eventonsite_conversion.messaging_conversation_started_7d— this is your CTWA conversiononsite_conversion.messaging_first_reply— first business reply in WhatsAppview_content,add_to_cart,initiate_checkout— funnel steps if Pixel is configured
For Click-to-WhatsApp specifically, look for action types prefixed with onsite_conversion.messaging_*. If they're missing, the campaign isn't a CTWA campaign or the pixel mapping isn't set up correctly.
purchase_roas shape
[
{"action_type": "omni_purchase", "value": "3.42"},
{"action_type": "purchase", "value": "3.18"}
]Use omni_purchase as the headline ROAS unless you have a reason to scope to web-only.
Breakdowns — what you can split data by
Pass to --breakdowns (one or more, but not all combinations are legal):
| Breakdown | Values | Compatible with |
|---|---|---|
publisher_platform | facebook, instagram, audience_network, messenger | most others |
platform_position | feed, instream_video, story, reels, search, etc. | publisher_platform |
device_platform | mobile_app, mobile_web, desktop | most |
impression_device | iphone, ipad, android_smartphone, ... | publisher_platform |
age | 13-17, 18-24, 25-34, 35-44, 45-54, 55-64, 65+ | gender |
gender | male, female, unknown | age |
country | ISO country codes | most |
region | sub-country region | country |
dma | Designated Market Area (US only — being phased out for Comscore Markets in 2026 for autos) | — |
product_id | for catalog ads | — |
hourly_stats_aggregated_by_advertiser_time_zone | hour of day | limited combos |
Action breakdowns are different — they split the actions array, not the rows:
| Action breakdown | Splits actions by |
|---|---|
action_type | (default — already in actions array) |
action_destination | URL for link clicks |
action_target_id | object the action was on |
action_device | the device the conversion happened on |
Date presets
Use these via --date-preset:
today, yesterday, last_3d, last_7d, last_14d, last_28d, last_30d, last_90d, this_week_mon_today, this_week_sun_today, last_week_mon_sun, last_week_sun_sat, this_month, last_month, this_quarter, last_quarter, this_year, last_year, maximum.
For custom ranges, use --since YYYY-MM-DD --until YYYY-MM-DD.
Time series via --time-increment
1= daily (one row per day per object)7= weekly (Mon–Sun)monthly= one row per calendar month- omitted = single row covering the whole period
Attribution windows — the confusion zone
By default, Meta returns numbers using your account's default attribution setting (usually 7-day click). To request specific windows, pass --action-attribution-windows:
1d_view— viewed ad, converted within 1 day, no click7d_view— same but 7 days1d_click— clicked, converted within 1 day7d_click— clicked, converted within 7 days (most common default)28d_click— clicked, converted within 28 days (deprecated for some objectives)
Important: the value in each action object is bound to one window — if you request multiple windows, each row gets per-window subkeys. This trips up everyone the first time.
Things Meta won't let you do (that you might try)
- Combine `breakdowns: age` with `breakdowns: country` AND a third dimension — usually fails with "Cannot have action breakdowns ... with the breakdowns".
- Request `reach` at the ad level over a long period broken down by demographic — Meta refuses on privacy grounds. Aggregate higher (campaign or account level) instead.
- Get hourly data older than 35 days.
- Get per-day rows for ranges over 90 days synchronously — falls back to async automatically in our
fetch_insights.py, but be patient. - Mix `purchase_roas` with `breakdowns=product_id` — known to silently return zeros. Use action_values + product_id breakdown instead.
When numbers don't match Ads Manager
This happens constantly. Usual causes: 1. Different attribution window. Ads Manager defaults can differ from API defaults. 2. Time zone. Ad account uses one TZ; you might be querying in another. 3. Currency conversion. spend is in the account currency. If the account is multi-currency, the rate at query time may differ from when the ad ran. 4. Late-arriving data. The current day is incomplete; conversions can take 24–72 hours to fully attribute. 5. Data freshness for "last 7d" — Meta sometimes lags 1–2 hours on hot data.
If a number is more than 5% off from Ads Manager, check attribution window first. If still off, query at a more granular level and sum — sometimes Meta's aggregate roll-up has reporting discrepancies.
Useful field combinations
Link CTR (the one that actually matters):
fields: impressions, actions[action_type=link_click]
formula: link_clicks / impressionsTrue CPA for an event:
fields: spend, actions[action_type=purchase]
formula: spend / purchase countOr just request cost_per_action_type and read the right one from the array.
CTWA cost per conversation:
fields: spend, actions
filter actions for action_type = onsite_conversion.messaging_conversation_started_7d
formula: spend / countOne-time Meta API setup — step by step
This is the only manual part of the skill. Meta's developer console involves real web-UI clicks and decisions only you can make, so we'll go slowly. Expect 15–25 minutes end to end, mostly waiting for Meta's UI to load.
Works on macOS, Windows, and Linux. Per-OS command blocks are shown side by side. You only need to run the commands for the OS you're on.
If anything on screen looks different from what's described below, don't panic — Meta redesigns this console regularly. Look for the same text labels even if colors or positions have shifted. If you get truly stuck, jump to references/troubleshooting.md.
---
Before you start: prerequisites
1. Python 3 installed on your machine
You need Python 3.8 or newer. Check which version you have:
macOS (Terminal):
/usr/bin/python3 --versionIf you see Python 3.x.x you're good. If you see "command not found", install Python:
# Easiest: install Homebrew-managed Python
brew install python
# Or download the installer from https://www.python.org/downloads/macos/Windows (PowerShell or cmd):
python --versionIf that fails, try py --version. If neither works, download the installer from https://www.python.org/downloads/windows/ and tick the "Add python.exe to PATH" checkbox on the first screen of the installer — without it, python won't work from the terminal. After install, close and reopen your terminal.
Linux (Bash):
python3 --versionIf missing: sudo apt install python3 python3-pip (Debian/Ubuntu) or your distro's equivalent.
2. The requests Python library
All scripts use one library: requests. Install it once:
macOS:
python3 -m pip install --user requestsWindows (PowerShell or cmd):
python -m pip install --user requestsIf python isn't recognized, try py -m pip install --user requests.
Linux (if you hit "externally-managed-environment"):
python3 -m pip install --user --break-system-packages requestsVerify it installed:
python3 -c "import requests; print(requests.__version__)" # macOS / Linux
python -c "import requests; print(requests.__version__)" # WindowsYou should see a version number like 2.31.0 print out.
3. A terminal open in the skill's directory
You'll be running scripts from inside the skills/meta-ads/ folder. Open a terminal there now so you don't have to re-navigate every time:
macOS (Terminal):
cd /path/to/skills/meta-adsWindows (PowerShell):
cd C:\path\to\skills\meta-adsEvery subsequent command in this guide assumes you're in that directory.
---
Step 0: Which path should you use?
Meta has two token types. Pick one by answering three yes/no questions:
1. Do you boost Instagram posts directly from the Instagram app? If yes → Path B. 2. Is the ad account you care about owned by a Meta Business Manager you created? If no → Path B. 3. Do you want a token that never expires, with all ads running from a single BM? If yes → Path A.
Still unsure? Pick Path B. A Path B (user) token sees everything a Path A (System User) token sees plus personal ad accounts created by Instagram boosts. You can always switch to Path A later.
Both paths share Steps 1 and 2 below. Do those first, then follow your chosen path.
---
Step 1: Create a Meta developer app (both paths)
1.1 Open the developer console
Open your browser and go to https://developers.facebook.com/apps. Log in with the Facebook account that has access to your ads. If this is your first time there, Meta may ask you to register as a developer — agree to the terms.
1.2 Start a new app
Look for a green or blue "Create App" button, usually in the top-right corner of the page. Click it.
1.3 Choose the use case
You'll land on a page titled "What do you want your app to do?" with several tile options. Select "Other" (usually in the bottom row). Click "Next" at the bottom.
1.4 Choose the app type
On the "Select an app type" page, pick "Business". Click "Next".
1.5 Fill in app details
- Display name: something descriptive for you only — e.g.,
my-meta-ads-skill. Nobody else will see this. - App contact email: your email.
- Business Account (optional): if you already have a Business Manager, select it from the dropdown. If not, skip — you can attach one later (or never, if you go Path B).
Click "Create app". Meta may ask for your password to confirm.
1.6 Land on the app dashboard
After creation you'll be taken to your app's dashboard — a page showing the app name at the top and a list of available products in the main panel. Keep this tab open; you'll come back to it.
---
Step 2: Add the Marketing API product (both paths)
2.1 Find the product list
Still on the app dashboard, scroll down to a section titled "Add products to your app" or look in the left-hand sidebar for "Add product".
2.2 Add Marketing API
Find the "Marketing API" tile. Click "Set up" on it. The page reloads and Marketing API appears in the left sidebar.
2.3 Note your App ID and App Secret
In the left sidebar, click "Settings" → "Basic". You'll see:
- App ID — a 15-digit number at the top.
- App Secret — hidden behind a "Show" button. Click it; you may need to re-enter your password.
Copy both to a password manager or a scratch file. You'll need the App ID in both paths, and the App Secret only if you're going Path B.
---
Path A: System User token (recommended if your ads are in a Business Manager)
Use this path only if you confidently answered "yes" to question 3 in Step 0. If you're unsure, skip down to Path B instead.
A.1 Make sure your ad account lives inside a Business Manager
1. Go to https://business.facebook.com/settings and log in. 2. If you've never created a Business Manager (BM), click "Create Account" at https://business.facebook.com. Pick a name for the BM, enter your name and email, click "Submit". 3. In the left sidebar: "Accounts" → "Ad Accounts". You should see the ad account listed under "Ad Accounts I Own". If it's listed under "Ad Accounts I Have Access To" instead, you don't own it and can't create a System User against it — you'll need to transfer ownership (through the account owner) or switch to Path B. 4. If the ad account isn't listed at all: click "Add" (top of the list) → "Add an ad account" → enter the ad account ID (format: 1234567890, the number from Ads Manager's URL). 5. Note your Business ID — visible at the top-right of the Business Settings page or in the URL (business_id=XXXXX).
A.2 Create a System User
1. Still in Business Settings, left sidebar: "Users" → "System Users". 2. Click "Add" (top-right of the system users list). 3. Name: something like meta-skill-system-user. Role: "Admin" (required for writes; pick "Employee" only if you want read-only). Click "Create System User". 4. You now see the new system user's detail page.
A.3 Grant the System User access to your ad accounts and app
1. On the system user's detail page, click "Add Assets" (usually top-right). 2. A panel opens with tabs: Pages, Ad Accounts, Apps, Catalogs, Pixels, …. 3. Ad Accounts tab: tick each ad account you want the skill to read/write. Under "Permissions", check "Manage campaigns" (gives read + write). For read-only, check "Read performance" only. Click "Save Changes". 4. Apps tab: tick the app you created in Step 1. Under "Permissions", check "Manage app" (or "Develop app" — both work). Click "Save Changes".
A.4 Generate the System User token
1. Still on the system user's page, click "Generate New Token". 2. Select App: the one you created in Step 1. 3. Permissions: tick ads_read, ads_management, and business_management. 4. Token expiration: "Never". (This is the whole point of a System User token.) 5. Click "Generate Token". 6. The token appears once — a long string starting with EAAB... or EAA.... Copy it immediately into a password manager. If you close this dialog without copying, you'll have to regenerate.
A.5 Find your ad account ID
Open Ads Manager in another tab (https://adsmanager.facebook.com). The URL contains act=1234567890. That 1234567890 is your ad account ID. The format the API expects is with an act_ prefix: `act_1234567890`.
A.6 Write the .env file
Inside the skills/meta-ads/ folder, copy the template:
macOS / Linux:
cp assets/env.template .envWindows (PowerShell):
Copy-Item assets\env.template .envWindows (cmd.exe):
copy assets\env.template .envNow open .env in your editor (TextEdit / Notepad / VS Code / whatever) and fill in:
META_ACCESS_TOKEN=EAAB...your_system_user_token...
META_AD_ACCOUNT_ID=act_1234567890
META_API_VERSION=v25.0You can leave META_APP_ID and META_APP_SECRET blank for Path A. Save the file.
A.7 Verify everything works
macOS / Linux:
python3 scripts/auth_check.py
python3 scripts/list_accounts.py --with-recent-spendWindows:
python scripts/auth_check.py
python scripts/list_accounts.py --with-recent-spendExpected output:
auth_check.pyprints"ok": true, your identity as the System User, and at least one ad account.list_accounts.pylists the accounts the System User can reach, sorted by recent spend. Find yours in the list and confirm it's the one you want.
If ok: false or no accounts appear, see the "Ad account discovery problems" section in references/troubleshooting.md.
You're done with Path A. Skip ahead to "What to put in your password manager".
---
Path B: Long-lived user token (for personal ad accounts or Instagram-app boosts)
B.1 Get a short-lived user token from Graph Explorer
1. In your browser, go to https://developers.facebook.com/tools/explorer. Make sure you're logged into the personal Facebook account that owns the ads or Instagram account you want to analyze. 2. Top-right, find the "Meta App" dropdown. Click it and select the app you created in Step 1. 3. Next dropdown, "User or Page": pick "User Token". 4. Click the "Permissions" dropdown/button. A popup opens with a long list of scopes. Tick:
ads_readads_managementbusiness_managementpages_show_list(needed if any boosted content came from a Page)
Close the permissions popup. 5. Click "Generate Access Token". A Facebook OAuth popup appears asking you to confirm. If it lists specific Pages, pick the ones associated with your Instagram account. Click "Continue" through the prompts. 6. The token now appears in the big text field at the top of Graph Explorer (long string starting with EAAB...). Copy it somewhere safe — a scratch file is fine.
This token only lasts ~1–2 hours. You're about to swap it for a 60-day one in step B.2. Don't dawdle.
B.2 Exchange for a 60-day long-lived token
1. First, fill in your App ID and App Secret (from Step 2.3) in .env. If .env doesn't exist yet, copy it:
macOS / Linux:
cp assets/env.template .envWindows (PowerShell):
Copy-Item assets\env.template .envWindows (cmd.exe):
copy assets\env.template .envOpen .env and set:
META_APP_ID=1234567890
META_APP_SECRET=abcdef1234567890abcdef1234567890Save.
2. Now run the exchange script. It will prompt you for the short-lived token and write the resulting 60-day token into .env automatically.
macOS / Linux:
python3 scripts/exchange_token.py --write-envWindows:
python scripts/exchange_token.py --write-env3. When prompted, paste the short-lived token from step B.1 and press Enter. Your input is hidden (no dots or characters appear) — that's intentional, paste and press Enter.
4. On success you'll see "ok": true, the expiration (~59.5 days in seconds), and META_ACCESS_TOKEN will be set in your .env.
5. The short-lived token is now useless — delete it from wherever you pasted it.
B.3 Discover your ad accounts
macOS / Linux:
python3 scripts/auth_check.py
python3 scripts/list_accounts.py --with-recent-spendWindows:
python scripts/auth_check.py
python scripts/list_accounts.py --with-recent-spendlist_accounts.py returns every ad account your Facebook user can see, sorted by last-30-day spend. Find the one with the activity you care about and copy its id value (format act_1234567890).
B.4 Pin the default ad account
Open .env and add or update:
META_AD_ACCOUNT_ID=act_1234567890Save. Now every script will default to this account without needing --account-id each time.
B.5 Re-verify
Run auth_check.py one more time:
macOS / Linux: python3 scripts/auth_check.py Windows: python scripts/auth_check.py
It should print "ok": true with your identity (your Facebook display name) and a list of visible accounts.
B.6 The 60-day rotation
Long-lived user tokens expire after ~60 days. Meta emails you a warning ~10 days out, but don't rely on it — set a calendar reminder for 55 days from now. When the token expires: 1. Repeat step B.1 (get a fresh short-lived token from Graph Explorer). 2. Run exchange_token.py --write-env again. Same flow, same outcome.
Your App ID and App Secret don't change, so .env just needs the new META_ACCESS_TOKEN.
---
App Review (you probably don't need this)
Your app will stay in "Development mode" — that's fine. Development mode means only you (and anyone you explicitly added as admin/developer of the app) can use the token to hit the API. For a personal-use skill, that's exactly what you want.
App Review is only required if you want other Meta users (not you, not your System User) to sign into your app and use it against their accounts. If that's your use case, this skill isn't the right shape — each user should run their own setup with their own app.
---
What to put in your password manager
Save these somewhere safe (1Password, Bitwarden, iCloud Keychain, etc.):
- App ID
- App Secret (only if you went Path B)
- Access token (System User token for Path A, 60-day user token for Path B)
- Business Manager ID (Path A only)
- Ad Account ID(s) (
act_…format)
These are the keys to your ad kingdom. Anyone with your token can spend money on your ads and read your ad data — treat the token like a credit card number.
Do not commit `.env` to git. If you're copying this skill into a repo of your own, add .env to your .gitignore before your first commit.
---
Troubleshooting
If any step failed or produced unexpected output, head to `references/troubleshooting.md` for the real-world failure modes we've already seen. The top ones:
auth_check.pyreturnsok: truebutad_accounts_visible: 0— token works but sees nothing. Fix in troubleshooting.md.(#3018) start date cannot be beyond 37 months— Meta only serves data from the last 37 months. Older campaigns are invisible via API; check Ads Manager's archived reports UI instead.ProxyError: Tunnel connection failed: 403— your environment can't reachgraph.facebook.com. Run the scripts on your own machine instead of a sandbox.ModuleNotFoundError: No module named 'requests'— install it with the command from the "prerequisites" section above.(#200) Permissions erroron a write call — your token doesn't haveads_managementscope (Path B) or the System User doesn't have "Manage campaigns" (Path A).
If none of those match, share the full JSON output of the failing script with the assistant — the error messages Meta returns are usually actionable.
Troubleshooting
Real failure modes observed when running this skill. Read the section that matches your symptom.
If none of these match, re-run python scripts/auth_check.py and share the full JSON output with the user — it's usually the fastest path to the root cause.
---
Auth / token problems
auth_check.py returns ok: true but ad_accounts_visible: 0
The token is valid, but the identity it resolves to can't see any ad accounts. Two causes:
Path A (System User token): the System User hasn't been granted access to any ad accounts yet. Go to Business Settings → Users → System Users → click the user → Add Assets → Ad Accounts tab → tick each account → grant "Manage campaigns" or "Read performance" → Save. Re-run auth_check.py.
Path B (user token): the FB user who generated the token at Graph Explorer doesn't own or have been granted access to any ad accounts. Two sub-causes:
- You were logged into the wrong FB account in Graph Explorer when you generated the token. Log out, log into the right one, repeat B.1.
- The OAuth popup during B.1 didn't include
ads_read/ads_management/business_managementscopes. Regenerate with those permissions checked.
auth_check.py returns ok: false with code 190
The token is expired or revoked. For Path B: tokens expire after ~60 days — repeat B.1 then B.2 in setup.md to get a fresh one. For Path A: System User tokens don't expire, so if you see 190 on one, either it was explicitly revoked in Business Settings or the System User itself was deleted. Regenerate.
auth_check.py returns ok: false with code 102 or 4
Session expired / app restriction. Almost always means the App Secret was regenerated or the app was put into "Live" mode without App Review. For personal use, the app should stay in "Development mode" — check App Dashboard → App Mode toggle.
exchange_token.py fails with (#100) Invalid OAuth access token
The short-lived token you pasted is already expired (they last ~1–2 hours) or mistyped. Go back to Graph Explorer, click "Generate Access Token" again, paste the fresh one immediately.
exchange_token.py fails with (#1) Unknown error or (#101) Missing client_id parameter
META_APP_ID and/or META_APP_SECRET are missing or wrong in .env. Re-copy both from your Meta developer app → Settings → Basic. The App Secret needs to be revealed via the "Show" button — make sure you copied the actual secret, not the placeholder dots.
(#200) Permissions error when calling a write endpoint
The token has ads_read but not ads_management. User tokens inherit only the scopes granted at OAuth time — regenerate with ads_management checked. System User tokens need the "Manage campaigns" role on the specific ad account (not just "Read performance").
---
Network / environment problems
requests.exceptions.ProxyError: Tunnel connection failed: 403 Forbidden
Your execution environment can't reach graph.facebook.com. This happens with Claude's Linux sandbox in some configurations — the outbound proxy blocks Meta's Graph domain.
Fix: run the scripts from the user's host machine (their Mac/Windows/Linux) instead of the sandbox. On macOS use /usr/bin/python3. Install deps on the host first:
/usr/bin/python3 -m pip install --user requestsThen run each script with the host Python:
/usr/bin/python3 /path/to/skills/meta-ads/scripts/auth_check.pyDon't try curl / wget / a different HTTP library — the block is at the proxy layer, not in requests.
ModuleNotFoundError: No module named 'requests'
Python doesn't have requests installed. Pick the command that matches your OS:
# macOS
python3 -m pip install --user requests
# Windows (PowerShell or cmd)
python -m pip install --user requests
# or if that fails:
py -m pip install --user requests
# Linux (normal case)
python3 -m pip install --user requests
# Linux with PEP 668 enforcement (Debian/Ubuntu 22+)
python3 -m pip install --user --break-system-packages requestsIf you installed it but still see the error, you probably have multiple Python versions on your machine. Install against the same interpreter you're running the script with — find it with:
- macOS / Linux:
which python3thenpython3 -m pip show requests - Windows:
where pythonthenpython -m pip show requests
SSLError / certificate verification failed
Either the host clock is badly wrong (check date on macOS/Linux or Get-Date in PowerShell) or a corporate proxy is intercepting TLS. For corporate networks, set REQUESTS_CA_BUNDLE to point at your org's CA bundle, or run on a personal machine.
---
Windows-specific issues
python is not recognized as an internal or external command
Python isn't on your PATH. Two fixes:
1. Re-run the Python installer from <https://www.python.org/downloads/windows/> and on the first screen tick "Add python.exe to PATH". Close and reopen your terminal after installing. 2. Use `py` instead. The Python launcher py is installed separately and usually works even when python doesn't:
py --version
py scripts/auth_check.py
py -m pip install --user requestsHebrew, Arabic, or other non-Latin characters show as ? or garbled in cmd.exe
cmd.exe's default code page is Windows-1252, which can't display Unicode. Switch to UTF-8 for the current session:
chcp 65001Then re-run your script. To make UTF-8 the default permanently, set the system locale in Region settings → Administrative → "Beta: Use Unicode UTF-8 for worldwide language support" (requires a reboot).
PowerShell and Windows Terminal usually handle UTF-8 correctly without changes.
Scripts run but .env values aren't being picked up
Windows line endings (CRLF) can break the .env parser on older tooling, though meta_client.py's loader handles CRLF correctly. If you suspect it's the issue, open .env in VS Code and check the bottom-right — if it says "CRLF" click to change to "LF" and save.
Copy-Item : Cannot find path in PowerShell when copying .env
You're not in the skill's directory, or the template name is different. Verify:
Get-Location # should end in \skills\meta-ads
Get-ChildItem assets # should list env.templatePowerShell execution policy prevents running scripts
You shouldn't hit this because the skill only uses .py files (not PowerShell scripts), but if you add a .ps1 wrapper and get "running scripts is disabled on this system", run:
Set-ExecutionPolicy -Scope CurrentUser RemoteSignedLong path errors when running scripts from deep directories
Windows has a legacy 260-character path limit. If your skill folder is nested very deep (e.g., C:\Users\you\OneDrive\Documents\Projects\whatever\ai-agents-skills\skills\meta-ads\…) and you see "file name too long" errors, either move the folder closer to the drive root or enable long paths in Windows:
# Run as admin
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1Then reboot.
---
Ad account discovery problems
list_accounts.py shows an account with account_status: 3 / UNSETTLED
The account has an unpaid balance. Meta still returns it and insights still work, but new campaigns can't be created until payment is resolved. Head to Ads Manager → Billing to clear it. This is not a bug in the script.
list_accounts.py shows account_status: 7 / PENDING_RISK_REVIEW
Meta has flagged the account for manual review. Read-only insights usually still work; writes don't. Appeal via Ads Manager → Account Quality. No API workaround.
I boosted a post from the Instagram app but list_accounts.py doesn't show its ad account
Two possibilities:
1. You're on a Path A (System User) token. IG-app boosts typically create a personal ad account attached to your FB profile, not inside any Business Manager — and System User tokens can only see BM-owned accounts. No amount of permissions fixes this because the account is structurally outside the BM. Switch to Path B (user token) and you'll see it. 2. The boost ran under a different FB account than the one you used at Graph Explorer. Check which FB login owns the Instagram account that did the boost.
"I added the ad account to the Business Manager, why doesn't the SU token see it?"
Two missing steps, in order: 1. BM owns the ad account → Business Settings → Accounts → Ad Accounts → it should be listed under "Ad Accounts I Own", not "Ad Accounts I Have Access To". If it's the latter, you don't have write access — contact the account owner to transfer it. 2. System User has been granted the ad account as an asset → Business Settings → Users → System Users → click the user → Add Assets → tick the account → save.
After both, regenerate the System User token (the old one's scope is cached at generation time).
Wrong ID pasted into META_AD_ACCOUNT_ID
Common mixup: in Business Settings the System User page shows the System User's ID prominently (a 15-digit number). People sometimes paste that into META_AD_ACCOUNT_ID. It's not an ad account ID.
Ad Account IDs come from Ads Manager's URL (act=<digits>) or from list_accounts.py output. The format the API expects is act_<digits>.
---
Insights / data problems
(#3018) The start date of the time range cannot be beyond 37 months from the current date
Meta's insights API only serves data from the last 37 months. Any campaign older than that is invisible to the API. If the user asks about a 2019 campaign in 2026, the answer is literally "the API can't tell you."
Workaround: open Ads Manager → Reports → Archived Reports, run the report in the UI, export CSV. Do not try to construct clever date ranges to sneak past the cap — Meta checks each range's start date against the 37-month wall.
(#1815001) Cannot Request for Deleted Objects
You tried to request effective_status values that include DELETED on the campaigns endpoint. Meta's campaigns edge doesn't support filtering on DELETED — it returns 1815001 rather than empty. list_campaigns.py already excludes DELETED from --status choices for this reason.
If you really need to see deleted campaigns, use Ads Manager's UI with the "Deleted" filter. There's no API path.
fetch_insights.py times out on a long date range
Switch to async mode: add --async or let the script auto-fall-back. Async insights jobs can run up to 30 minutes server-side; sync calls time out at ~60s.
If --async also fails with "Job Failed", your query is likely too broad for Meta's async quota too. Split the date range (e.g., one month at a time) or reduce breakdowns.
Insights return spend: "0" but Ads Manager shows spend
Three common causes: 1. Attribution window mismatch. Ads Manager defaults to 7-day-click + 1-day-view. API defaults to a narrower window depending on version. Pass action_attribution_windows=['7d_click','1d_view'] explicitly in the insights query. 2. Level mismatch. You queried level=account but the campaign is on a sub-account. Check the account_id on the campaign. 3. Currency confusion. spend is returned in the account's currency (as a decimal string in major units for insights — unlike amount_spent on the account object, which is minor units). Don't confuse the two.
Click numbers seem too high
clicks includes every click anywhere on the ad — reactions, "See more" expansions, profile clicks. For outbound link clicks only, use inline_link_clicks. For the metric Ads Manager calls "Link clicks", it's actions where action_type = link_click.
---
Display / encoding problems
Hebrew / Arabic / Chinese campaign names show as \u05d2\u05dd... in output
JSON escaping. Scripts output ensure_ascii=False so this shouldn't happen when you run them directly. If you see escaped Unicode, you're probably viewing the output through a pipe that doesn't handle UTF-8 — on Windows cmd.exe, run chcp 65001 first; on macOS/Linux it usually Just Works unless LANG is set to C.
amount_spent is a weirdly large number like 1691697
That's minor units (agorot / cents / pence). Divide by 100 for most currencies. For zero-decimal currencies (JPY, KRW, VND, CLP, ISK, UGX, XAF, XOF, TWD) don't divide. list_accounts.py already does this conversion and exposes it as amount_spent_major. The raw amount_spent field is preserved for anyone who needs it.
---
Write action problems
pause_ad.py or update_budget.py returns ok: true but the change didn't happen in Ads Manager
Ads Manager's UI caches state for 30–60 seconds. Refresh the browser. If it's still wrong after a minute, pull the ad back via the API — the truth is what the API returns, not the cached UI.
Budget change rejected with (#100) Invalid parameter
Either the new budget is below Meta's minimum for the currency, or above the account's spend_cap, or you passed it in the wrong units. Budgets on write are passed in minor units (same as amount_spent) — the scripts convert for you, but if you're calling the API directly, pass daily_budget=500 for ₪5.00, not daily_budget=5.
"I wanted to duplicate an ad, why did the copy show up paused?"
Meta always creates copies in PAUSED status by default — this is intentional so you don't immediately spend money on an unverified copy. Unpause via pause_ad.py --unpause <new_ad_id> after reviewing the copy in Ads Manager.
---
When in doubt
1. Run auth_check.py — does the token even work? 2. Run list_accounts.py --with-recent-spend — do you see the account you expect? 3. Run list_campaigns.py --account-id act_xxx — does the campaign list match Ads Manager? 4. If 1–3 all look right but insights are wrong, run the same query in Ads Manager UI and compare — API vs UI discrepancies almost always come down to attribution windows or date ranges.
If you're still stuck, share the full JSON output of whichever script is misbehaving — the errors from Meta are reasonably informative once you can see them.
Write Actions: Safety Protocol
This file is mandatory reading before calling pause_ad.py, update_budget.py, duplicate_ad.py, or create_campaign.py. These scripts touch real money. A misread analysis or a confidently-wrong recommendation can pause a winning ad, 10x a losing budget, duplicate the wrong creative, or launch a campaign against the wrong landing page. There's no "undo" button — Meta will execute the change instantly and you'll be reconstructing the previous state from effective_status history if it goes wrong.
The five non-negotiable rules
1. Explicit confirmation in chat for every single write
A previous "go ahead" or "yes do whatever you think" in the conversation does NOT count. Each individual write call needs its own confirmation message. The user has to actively re-affirm. This is friction by design — the friction is the safety feature.
What confirmation looks like, minimally:
- The user explicitly names the action (
yes pause it,confirm,כן תשנה,בצע) - It's the user's most recent message before the write call
- It's not just "ok" or "👍" in response to something else — it has to be unambiguous about the specific action
If you're unsure whether a message is a confirmation, treat it as not. Ask again.
2. Surface the impact before asking
Don't just say "should I pause ad 12345?" Say:
Pause ad `Summer_Launch_v3` (ID 120214567890)?- Spent ₪47.20 over the last 7 days
- 0.8% CTR (vs your account avg 1.4%)
- 12 link clicks, 0 conversions
- Ad set still has 3 other ads delivering, so the ad set won't go dark
>
Confirm with yes to proceed.The user can only consent to what they understand. If you can't explain the impact in 3-5 lines, you don't understand it well enough to recommend the action.
3. One action per confirmation, by default
If the analysis suggests pausing 12 ads, don't bundle them into one mega-confirmation. Either:
- Walk through them one at a time (slower but safest), OR
- Present a numbered list of all 12 and explicitly ask for "pause all 12" or "pause #1, #3, #7" — accept partial selections
Never assume a previous batch confirmation extends to a new batch. If the user said "pause those 3 fatigued ads" and you found 5 more later in the conversation, the new 5 need their own confirmation.
4. Always dry-run when the script supports it
Every write script accepts --dry-run. Use it first when:
- This is the first write of the session
- The change is large (>50% budget swing, bulk pause, deep_copy duplications)
- You're acting on data you fetched more than 30 minutes ago (it might be stale)
- The user is testing the skill and hasn't done a write before
The dry-run output shows exactly what would change. Read it back to the user and ask for the real confirmation.
5. Never chain writes without intermediate verification
If the plan is "duplicate the winning ad, then pause the original" — don't run both as a single batch. Run the duplicate, fetch the new ID, confirm to the user that the duplicate is created and in the expected state, then ask to pause the original. Mid-chain failures are the worst kind: you can end up with the original paused and the duplicate broken, leaving the ad set with nothing delivering.
Per-script safety notes
pause_ad.py
- Status
PAUSEDstops delivery immediately. Spending stops within minutes (not seconds — there's a small in-flight auction tail). - Status
ACTIVEresumes. The ad re-enters the learning phase if it had been paused for >7 days. - Pausing a campaign also stops all ad sets and ads under it; pausing an ad set stops only its ads.
- A paused ad in an active ad set with active sibling ads has no effect on overall delivery — Meta just shifts spend to the siblings. This is usually desirable.
- A paused ad in an ad set where it was the only active ad means the ad set goes dark. Surface this to the user before pausing.
update_budget.py
- The 2x cap per call is a hard rule. To go from ₪50/day to ₪200/day, do it in steps: ₪50 → ₪100 → ₪175 → ₪200, with confirmations between. Override with
--allow-large-increaseonly if the user explicitly says "yes I understand the risk, do the full 4x". - Any change >20% triggers Meta's learning-phase reset. The script's output includes a
warningfield when this applies. Mention it to the user — they should expect 3-7 days of unstable performance after a big budget move. - Decreases >50% can drive Meta to pause the ad set automatically because the budget is "too low to deliver". Watch for
effective_status: CAMPAIGN_PAUSEDin subsequent fetches. - Lifetime budgets and daily budgets are mutually exclusive on a given object. The script auto-detects which is set; if both or neither are returned, it refuses to act.
- For campaign budget optimization (CBO) campaigns, budget lives at the campaign level, not the ad set. The script handles this correctly because it queries the object first, but be aware: passing an ad set ID for a CBO campaign will fail with a clear error.
duplicate_ad.py
- Default
--status PAUSEDis the right default. Only override if the user explicitly wants the duplicate to go live immediately. deep_copy=true(used for campaigns) duplicates the entire tree — campaign, ad sets, ads, creatives. For a campaign with 5 ad sets and 20 ads, that's 26 new objects. Tell the user the count before duplicating.- Duplicating an ad set into a different campaign (
--target-parent-id) requires the target campaign to have a compatible objective. Mismatched objectives → API error. - Naming: Meta's default suffix is " - Copy". Use
--renameto keep the account organized — naming chaos compounds fast across iterations. - Duplicate ≠ fresh creative. A duplicated ad is the same ad, just a new ID. It enters its own learning phase but the creative is identical. If the original is fatigued because of creative burnout, duplicating won't help — only new creative will.
create_campaign.py
This is the newest and highest-blast-radius write. One --confirm run produces a campaign plus N ad sets plus M creatives plus M ads — typically 5-10+ new objects from a single command. Get it wrong and you're rolling back a tree of objects spread across Ads Manager.
- Dry-run is not optional. Before the first
--confirmof any spec, run--dry-runand walk the full plan back to the user: campaign name, objective, identity (page + IG), every ad set's targeting and budget, every ad's headline and CTA. Only ask for confirmation after the user has seen this. - All objects default to PAUSED. Don't override
status: ACTIVEon creation unless the user has explicitly typed "create ACTIVE" or the equivalent in their language. The PAUSED default is a checkpoint — the user flips to ACTIVE in Ads Manager once they've eyeballed the creative, which catches problems the API can't (wrong image, weird Hebrew rendering, headline that looks fine at 40 chars in a spec but truncates ugly on mobile). - Never launch a conversions campaign against an account with no pixel. Even if the user asks for
objective: OUTCOME_SALES, if/me/pixelsreturns empty or no Purchase events have fired in 30+ days, refuse and explain why: Meta has nothing to optimize toward, spend will go to random clicks, and the campaign will look broken by week 2. OfferOUTCOME_TRAFFICas the interim path. Document this decision to the user before proceeding. - Watch for `UNSETTLED` on the ad account.
list_accounts.pyreports account status. If the account is unsettled, objects get created but nothing delivers. That's fine — surface it, don't block. Users typically want the structure in place while they clear the balance. - `special_ad_categories` matters. If the campaign is for housing, employment, credit, or political/social issues, the spec MUST set the right category. Wrong category = total campaign rejection on review, with a Meta penalty counter. If the user's copy mentions loans, interest rates, job listings, or political figures, confirm the category before
--confirm. - Creative review expects accurate claims. Social proof numbers ("10,000+ enrolled"), ranking claims ("#1 in Israel"), guarantee language, celebrity endorsements — all get reviewed. Before
--confirm, surface every numeric or superlative claim baked into the image or copy and ask the user whether it's truthful and provable. Fabricated social proof isn't just ethically bad — it gets the whole creative banned. - State file is the rollback surface. Every
--confirmwrites<spec>_state_<timestamp>.jsonwith every object ID in creation order. Keep it. If anything's wrong after creation,rollback_creation.py --state <file> --pausesoft-stops everything in one call. - One campaign at a time. Don't batch multiple
--confirmcalls. Each campaign is a separate spec, separate dry-run, separate explicit confirmation. This limits blast radius if the spec is broken. - Never chain create → activate. If the user wants the campaign live immediately, that's a separate subsequent confirmation: create (PAUSED), verify the objects look right by fetching them back and reading creative previews, then call the activation as its own write.
When something goes wrong
If you executed a write and the user wants to undo it:
- Pause → Active rollback: call
pause_ad.py --status ACTIVEon the same ID. Note: if the ad was paused for >7 days, it re-enters learning. - Budget rollback: call
update_budget.pywith the previous value. Each script's success output includes the previous value (status_before,before_minor) — capture these into the conversation context immediately so they're available for rollback. - Duplicate rollback: the duplicate has a new ID returned in the output. To "undo," delete or pause the new copy. Deleting requires
delete()on the meta_client — there's no bundled script because permanent deletion is risky; do it via the Meta UI unless the user really wants programmatic delete.
What this skill will NOT help with
- Bulk operations across accounts. Each call is scoped to one ad account at a time. Multi-account bulk = multi-tenant work, wrong skill.
- Editing existing creatives. You can CREATE new creatives via
create_campaign.py(link_data with image upload). Modifying the image or copy on an already-live creative is a different shape — Meta requires a new creative object and swap on the ad. Out of scope for v1. - Dynamic creative, catalog ads, collection ads.
create_campaign.pyhandles single-image link ads only. Those other formats useasset_feed_spec,product_set_id, ortemplate_data— extend the script or write a sibling for them. - Custom Audience creation or modification. You can USE existing Custom Audience IDs in
targeting.custom_audiences, but building a new audience (from pixel events, customer lists, IG engagers, etc.) is a separate API surface and out of scope for v1. - Pixel / CAPI event manipulation. Different API entirely (Conversions API), different auth, different mental model.
- Scheduled writes. This skill is on-demand. If the user wants "pause all ads with frequency >4 every Monday morning," that belongs in a scheduler (cron, a scheduled-tasks integration, etc.), not inside this skill.
Scope note: personal-scale vs. agency / enterprise
This skill is built for personal-scale ad management — one operator, one set of credentials, a handful of ad accounts. At that scale individual writes matter and confirmations are easy.
If this ever gets pointed at multi-tenant use (an agency managing dozens of client Business Managers, or an in-house team acting on shared client accounts), the rules in this file are insufficient. You'd need per-client credential storage, an audit log of every write with operator identity, role-based access, and probably a four-eyes approval flow for anything above a threshold. Don't try to retrofit this skill — build a separate multi-tenant version. Each account owner should run their own setup with their own Meta app and their own System User token.
requests>=2.28
#!/usr/bin/env python3
"""Detect anomalies by comparing the current period to the prior equivalent.
For example, last 7 days vs the 7 days before that. Flags significant
changes in spend, CTR, CPM, CPC, and ROAS at the campaign level.
The threshold for "significant" is configurable. By default, a metric
must change by >= 25% AND the absolute change must matter (e.g. spend
must move at least $X in either direction). This avoids screaming about
a campaign that went from $0.50 to $0.75 spend.
Usage:
python scripts/anomaly_detect.py --window-days 7
python scripts/anomaly_detect.py --window-days 14 --pct-threshold 0.30
"""
from __future__ import annotations
import argparse
import sys
from datetime import date, timedelta
from meta_client import MetaAPIError, normalize_account_id, paginate, print_json
def fetch_window(account: str, since: date, until: date) -> dict:
params = {
"level": "campaign",
"fields": ",".join(
[
"campaign_id",
"campaign_name",
"spend",
"impressions",
"clicks",
"ctr",
"cpm",
"cpc",
"purchase_roas",
"actions",
]
),
"time_range": f'{{"since":"{since.isoformat()}","until":"{until.isoformat()}"}}',
"limit": 100,
}
return {row["campaign_id"]: row for row in paginate(f"{account}/insights", params) if "campaign_id" in row}
def safe_float(v) -> float:
try:
return float(v)
except (TypeError, ValueError):
return 0.0
def extract_purchase_roas(row) -> float:
"""purchase_roas is a list like [{"action_type":"omni_purchase","value":"3.4"}]."""
roas_list = row.get("purchase_roas") or []
for r in roas_list:
if r.get("action_type") in ("omni_purchase", "purchase"):
return safe_float(r.get("value"))
return 0.0
def pct_change(current: float, prior: float) -> float | None:
"""Return the percent change as a fraction (0.5 = +50%). None if undefined."""
if prior == 0:
return None if current == 0 else float("inf")
return (current - prior) / prior
def classify_change(metric: str, change: float | None, current: float, prior: float, abs_floor: dict) -> dict | None:
"""Return an anomaly dict if this change is worth flagging, else None."""
if change is None or change == 0:
return None
floor = abs_floor.get(metric, 0)
if abs(current - prior) < floor:
return None
return {
"metric": metric,
"prior": prior,
"current": current,
"abs_change": current - prior,
"pct_change": change,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--account-id", help="Ad account ID. Defaults to env.")
parser.add_argument(
"--window-days", type=int, default=7, help="Length of comparison window in days. Default 7."
)
parser.add_argument(
"--pct-threshold",
type=float,
default=0.25,
help="Minimum percent change to flag (0.25 = 25%%). Default 0.25.",
)
parser.add_argument(
"--min-spend",
type=float,
default=10.0,
help="Skip campaigns with less than this spend in either window. Default 10.",
)
args = parser.parse_args()
account = normalize_account_id(args.account_id)
today = date.today()
current_until = today - timedelta(days=1) # yesterday
current_since = current_until - timedelta(days=args.window_days - 1)
prior_until = current_since - timedelta(days=1)
prior_since = prior_until - timedelta(days=args.window_days - 1)
try:
current = fetch_window(account, current_since, current_until)
prior = fetch_window(account, prior_since, prior_until)
except MetaAPIError as e:
print_json({"ok": False, "error": str(e), "meta_error": e.body.get("error")})
return 1
# Floors for "absolute change worth caring about" so we don't alert on noise.
abs_floor = {
"spend": args.min_spend / 2, # at least half the min-spend in absolute movement
"ctr": 0.005, # 0.5 percentage points
"cpm": 1.0, # $1 CPM swing
"cpc": 0.10,
"purchase_roas": 0.3,
}
anomalies = []
all_campaign_ids = set(current.keys()) | set(prior.keys())
for cid in all_campaign_ids:
c_row = current.get(cid, {})
p_row = prior.get(cid, {})
c_spend = safe_float(c_row.get("spend"))
p_spend = safe_float(p_row.get("spend"))
if c_spend < args.min_spend and p_spend < args.min_spend:
continue
flags: list[dict] = []
for metric, get_value in [
("spend", lambda r: safe_float(r.get("spend"))),
("ctr", lambda r: safe_float(r.get("ctr"))),
("cpm", lambda r: safe_float(r.get("cpm"))),
("cpc", lambda r: safe_float(r.get("cpc"))),
("purchase_roas", extract_purchase_roas),
]:
cv = get_value(c_row)
pv = get_value(p_row)
change = pct_change(cv, pv)
if change is None:
continue
if change != float("inf") and abs(change) < args.pct_threshold:
continue
classified = classify_change(metric, change, cv, pv, abs_floor)
if classified:
flags.append(classified)
if not flags:
continue
# Check for "campaign disappeared" or "new campaign appeared" cases.
if c_spend == 0 and p_spend > 0:
status = "stopped_spending"
elif p_spend == 0 and c_spend > 0:
status = "started_spending"
else:
status = "active_change"
anomalies.append(
{
"campaign_id": cid,
"campaign_name": c_row.get("campaign_name") or p_row.get("campaign_name"),
"status": status,
"current_spend": c_spend,
"prior_spend": p_spend,
"anomalies": flags,
# severity = number of metrics flagged, weighted by total spend
"severity_score": len(flags) * (c_spend + p_spend),
}
)
anomalies.sort(key=lambda a: a["severity_score"], reverse=True)
print_json(
{
"ok": True,
"account_id": account,
"current_window": {
"since": current_since.isoformat(),
"until": current_until.isoformat(),
},
"prior_window": {
"since": prior_since.isoformat(),
"until": prior_until.isoformat(),
},
"thresholds": {
"pct_change": args.pct_threshold,
"min_spend": args.min_spend,
},
"anomaly_count": len(anomalies),
"anomalies": anomalies,
}
)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Verify Meta API credentials work.
Outputs JSON with token info and a count of accessible ad accounts.
Exits non-zero if anything is broken so calling code can detect failure.
Usage: python scripts/auth_check.py
"""
from __future__ import annotations
import argparse
import sys
from meta_client import MetaAPIError, get, get_token, get_version, print_json
# Keep in sync with list_accounts.py
STATUS_NAMES = {
1: "ACTIVE",
2: "DISABLED",
3: "UNSETTLED",
7: "PENDING_RISK_REVIEW",
8: "PENDING_SETTLEMENT",
9: "IN_GRACE_PERIOD",
100: "PENDING_CLOSURE",
101: "CLOSED",
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.parse_args() # No args needed; this exists so --help works.
try:
token = get_token()
except RuntimeError as e:
print_json({"ok": False, "stage": "load_token", "error": str(e)})
return 1
# Hit /me to confirm the token is alive and get the user/system-user identity.
try:
me = get("me", params={"fields": "id,name"})
except MetaAPIError as e:
print_json(
{
"ok": False,
"stage": "verify_token",
"error": str(e),
"meta_error": e.body.get("error"),
}
)
return 1
# Check what ad accounts this token can read.
try:
accounts = get(
"me/adaccounts",
params={"fields": "id,name,account_status,currency,timezone_name", "limit": 100},
)
except MetaAPIError as e:
print_json(
{
"ok": False,
"stage": "list_adaccounts",
"error": str(e),
"meta_error": e.body.get("error"),
"hint": (
"Token is valid but can't read ad accounts. "
"Make sure 'ads_read' (and 'ads_management' for write) "
"permissions were granted. If you're using a System User token, "
"the System User must also be granted access to each ad account "
"in Business Settings. If your ads are Instagram boosts on a "
"personal ad account outside any BM, switch to a user token "
"(Path B in references/setup.md)."
),
}
)
return 1
ad_accounts = accounts.get("data", [])
# Flag accounts that might surprise the user.
surprises = []
status_codes = [a.get("account_status") for a in ad_accounts]
if 3 in status_codes:
surprises.append(
"One or more accounts have status UNSETTLED (unpaid balance). "
"Reads work; you can't launch new campaigns until billing is cleared."
)
if 2 in status_codes:
surprises.append(
"One or more accounts are DISABLED (likely policy issues). Insights may still read."
)
if len(ad_accounts) == 0:
surprises.append(
"Token is valid but sees zero ad accounts. "
"If you boost from the Instagram app, your account is probably personal — "
"use a user token (Path B) instead of a System User token."
)
print_json(
{
"ok": True,
"api_version": get_version(),
"identity": me,
"ad_accounts_visible": len(ad_accounts),
"ad_accounts": [
{
"id": a.get("id"),
"name": a.get("name"),
"status_code": a.get("account_status"),
"status": STATUS_NAMES.get(
a.get("account_status"), f"UNKNOWN({a.get('account_status')})"
),
"currency": a.get("currency"),
"timezone": a.get("timezone_name"),
}
for a in ad_accounts
],
"token_length": len(token),
"warnings": surprises,
"next_step": (
"Run `python scripts/list_accounts.py --with-recent-spend` to see "
"lifetime + recent spend per account and pick the right one for "
"META_AD_ACCOUNT_ID in your .env."
),
}
)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Create a complete Meta ad campaign from a spec JSON.
Builds the full object tree in one shot:
campaign -> ad sets -> creatives + ads
Why a script and not Ads Manager UI? Campaigns built from a spec are
reproducible, reviewable (dry-run), and rollback-able (every ID is
logged to a state file). Good for A/B testing flights, agency handoffs,
and any time you want the exact same structure twice.
Safety:
- Refuses to write without either --dry-run or --confirm.
- Defaults the created campaign, ad sets, and ads all to PAUSED so
nothing delivers until you explicitly enable them in Ads Manager.
- Every object created is recorded in the returned state JSON with
its ID, so you can delete/pause everything if something went wrong.
Usage:
python scripts/create_campaign.py --spec spec.json --dry-run
python scripts/create_campaign.py --spec spec.json --confirm
See references/campaign-creation.md for the spec format.
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from meta_client import (
GRAPH_BASE,
MetaAPIError,
get,
get_token,
get_version,
normalize_account_id,
post,
print_json,
)
# Currencies with no minor unit (Meta returns/accepts whole-unit amounts).
# Source: ISO 4217 zero-decimal list, aligned with Meta's docs.
ZERO_DECIMAL = {"JPY", "KRW", "VND", "ISK", "TWD", "XAF", "XOF", "CLP"}
DEFAULT_PLACEMENTS = {
"publisher_platforms": ["instagram"],
"instagram_positions": ["stream", "story", "reels"],
}
VALID_CTAS = {
"LEARN_MORE", "SIGN_UP", "SHOP_NOW", "BOOK_TRAVEL", "DOWNLOAD",
"GET_OFFER", "GET_QUOTE", "SUBSCRIBE", "CONTACT_US", "APPLY_NOW",
"WATCH_MORE", "INSTALL_MOBILE_APP", "USE_APP", "MESSAGE_PAGE",
"WHATSAPP_MESSAGE", "NO_BUTTON",
}
# ─── Helpers ───────────────────────────────────────────────────────────────
def get_account_currency(account_id: str) -> str:
"""Fetch the account's currency so we can do major→minor conversion correctly."""
resp = get(account_id, {"fields": "currency"})
return resp.get("currency", "USD")
def major_to_minor(amount_major: float, currency: str) -> int:
"""Convert 50.00 ILS → 5000 agorot. 1000 JPY → 1000."""
if currency.upper() in ZERO_DECIMAL:
return int(round(amount_major))
return int(round(amount_major * 100))
def resolve_interest_ids(interest_names: list[str]) -> list[dict]:
"""Resolve interest names → {id, name} dicts via Meta's ad interest search.
Keeps the first match per name. Names with no match are dropped and
surfaced to the caller as an issue.
"""
out = []
seen = set()
missing = []
for name in interest_names:
resp = get("search", {"type": "adinterest", "q": name, "limit": 3})
hits = resp.get("data", [])
if not hits:
missing.append(name)
continue
top = hits[0]
tid = top.get("id")
if tid and tid not in seen:
out.append({"id": tid, "name": top.get("name")})
seen.add(tid)
if missing:
print(f"[warn] No interest match for: {missing}", file=sys.stderr)
return out
def upload_image(account_id: str, image_path: Path) -> str:
"""POST image bytes to /act_.../adimages. Returns the hash Meta assigns."""
import requests
url = f"{GRAPH_BASE}/{get_version()}/{account_id}/adimages"
with image_path.open("rb") as f:
files = {image_path.name: (image_path.name, f, "image/png")}
data = {"access_token": get_token()}
resp = requests.post(url, data=data, files=files, timeout=120)
if not resp.ok:
raise RuntimeError(f"Image upload failed ({resp.status_code}): {resp.text[:500]}")
body = resp.json()
info = body.get("images", {}).get(image_path.name)
if not info or not info.get("hash"):
raise RuntimeError(f"No hash in upload response: {body}")
return info["hash"]
def build_targeting(t: dict, resolved_interests: list[dict]) -> dict:
out = {}
# Geo
geo = t.get("geo_locations")
if not geo:
countries = t.get("countries")
if countries:
geo = {"countries": countries}
if geo:
out["geo_locations"] = geo
# Age/gender
if "age_min" in t:
out["age_min"] = int(t["age_min"])
if "age_max" in t:
out["age_max"] = int(t["age_max"])
if "genders" in t:
out["genders"] = t["genders"]
# Interests via flexible_spec (AND of interest group + other criteria)
if resolved_interests:
out["flexible_spec"] = [
{"interests": [{"id": i["id"], "name": i["name"]} for i in resolved_interests]}
]
# Placements
out["publisher_platforms"] = t.get("publisher_platforms", DEFAULT_PLACEMENTS["publisher_platforms"])
if "instagram" in out["publisher_platforms"]:
out["instagram_positions"] = t.get(
"instagram_positions", DEFAULT_PLACEMENTS["instagram_positions"]
)
if "facebook" in out["publisher_platforms"]:
out["facebook_positions"] = t.get("facebook_positions", ["feed", "story", "video_feeds"])
# Advantage+ audience expansion (default ON)
if t.get("advantage_audience", True):
out["targeting_automation"] = {"advantage_audience": 1}
# Custom audiences + excluded audiences (optional)
if "custom_audiences" in t:
out["custom_audiences"] = t["custom_audiences"]
if "excluded_custom_audiences" in t:
out["excluded_custom_audiences"] = t["excluded_custom_audiences"]
return out
# ─── Validation ────────────────────────────────────────────────────────────
def validate_spec(spec: dict) -> list[str]:
"""Return a list of human-readable errors. Empty list = valid."""
errs = []
if not spec.get("campaign_name"):
errs.append("campaign_name is required")
if not spec.get("objective"):
errs.append("objective is required (e.g. OUTCOME_TRAFFIC, OUTCOME_SALES, OUTCOME_ENGAGEMENT)")
if not spec.get("landing_url") and spec.get("objective", "").startswith("OUTCOME_TRAFFIC"):
errs.append("landing_url is required for traffic campaigns")
identity = spec.get("identity", {})
if not identity.get("page_id"):
errs.append("identity.page_id is required")
if not spec.get("ad_sets"):
errs.append("at least one ad_set is required")
for i, a in enumerate(spec.get("ad_sets", [])):
prefix = f"ad_sets[{i}]"
if not a.get("name"):
errs.append(f"{prefix}.name is required")
if "daily_budget" not in a and "lifetime_budget" not in a:
errs.append(f"{prefix} needs daily_budget or lifetime_budget (in major units)")
if not a.get("ads"):
errs.append(f"{prefix}.ads must have at least one ad")
# Image required unless image_hash is pre-supplied
if not a.get("image_hash") and not a.get("image_path"):
errs.append(f"{prefix} needs either image_path or image_hash")
if a.get("image_path"):
p = Path(a["image_path"]).expanduser()
if not p.is_absolute():
p = (Path.cwd() / p).resolve()
if not p.exists():
errs.append(f"{prefix}.image_path does not exist: {p}")
for j, ad in enumerate(a.get("ads", [])):
ap = f"{prefix}.ads[{j}]"
for f in ("name", "message", "headline"):
if not ad.get(f):
errs.append(f"{ap}.{f} is required")
cta = ad.get("cta", "LEARN_MORE")
if cta not in VALID_CTAS:
errs.append(f"{ap}.cta '{cta}' is not a recognized CTA type")
return errs
# ─── Planning (dry-run) ────────────────────────────────────────────────────
def plan(spec: dict, account_id: str) -> dict:
currency = get_account_currency(account_id)
out = {
"account_id": account_id,
"currency": currency,
"campaign": {
"name": spec["campaign_name"],
"objective": spec["objective"],
"status": spec.get("status", "PAUSED"),
"special_ad_categories": spec.get("special_ad_categories", []),
},
"identity": spec["identity"],
"landing_url": spec.get("landing_url"),
"ad_sets": [],
}
total_daily_minor = 0
total_ads = 0
for a in spec["ad_sets"]:
targeting = a.get("targeting", {})
# Resolve interests now so the dry-run tells the user the real IDs
interests = []
if targeting.get("interest_ids"):
interests = [{"id": i, "name": f"<preset:{i}>"} for i in targeting["interest_ids"]]
elif targeting.get("interests"):
interests = resolve_interest_ids(targeting["interests"])
daily_minor = None
if "daily_budget" in a:
daily_minor = major_to_minor(a["daily_budget"], currency)
total_daily_minor += daily_minor
out["ad_sets"].append(
{
"name": a["name"],
"daily_budget_major": a.get("daily_budget"),
"daily_budget_minor": daily_minor,
"lifetime_budget_major": a.get("lifetime_budget"),
"billing_event": a.get("billing_event", "LINK_CLICKS"),
"optimization_goal": a.get("optimization_goal", "LINK_CLICKS"),
"targeting_summary": {
"countries": targeting.get("geo_locations", {}).get("countries")
or targeting.get("countries"),
"age_range": [targeting.get("age_min"), targeting.get("age_max")],
"resolved_interests": interests,
"placements": targeting.get(
"publisher_platforms", DEFAULT_PLACEMENTS["publisher_platforms"]
),
},
"image": a.get("image_path") or f"<prehashed:{a.get('image_hash')}>",
"ads": [
{
"name": ad["name"],
"headline": ad["headline"],
"message_preview": ad["message"].split("\n", 1)[0][:100] + " …",
"cta": ad.get("cta", "LEARN_MORE"),
}
for ad in a["ads"]
],
}
)
total_ads += len(a["ads"])
out["totals"] = {
"ad_sets": len(spec["ad_sets"]),
"ads": total_ads,
"total_daily_budget_minor": total_daily_minor,
"total_daily_budget_major": total_daily_minor / (1 if currency.upper() in ZERO_DECIMAL else 100),
}
return out
# ─── Execution (write) ─────────────────────────────────────────────────────
def execute(spec: dict, account_id: str) -> dict:
currency = get_account_currency(account_id)
state: dict = {
"ok": True,
"account_id": account_id,
"currency": currency,
"created_at": int(time.time()),
"objects": [],
}
# 1. Campaign
camp_data = {
"name": spec["campaign_name"],
"objective": spec["objective"],
"status": spec.get("status", "PAUSED"),
"special_ad_categories": json.dumps(spec.get("special_ad_categories", [])),
"buying_type": spec.get("buying_type", "AUCTION"),
}
camp = post(f"{account_id}/campaigns", data=camp_data)
campaign_id = camp["id"]
state["campaign_id"] = campaign_id
state["objects"].append({"type": "campaign", "id": campaign_id, "name": spec["campaign_name"]})
print(f"[+] campaign: {campaign_id} — {spec['campaign_name']}", file=sys.stderr)
page_id = spec["identity"]["page_id"]
ig_user_id = spec["identity"].get("instagram_user_id")
landing_url = spec.get("landing_url")
out_ad_sets = []
for a in spec["ad_sets"]:
targeting_cfg = a.get("targeting", {})
# Resolve interests
if targeting_cfg.get("interest_ids"):
interests = [{"id": i, "name": f"preset-{i}"} for i in targeting_cfg["interest_ids"]]
elif targeting_cfg.get("interests"):
interests = resolve_interest_ids(targeting_cfg["interests"])
else:
interests = []
targeting = build_targeting(targeting_cfg, interests)
# Image
if a.get("image_hash"):
image_hash = a["image_hash"]
else:
img_path = Path(a["image_path"]).expanduser()
if not img_path.is_absolute():
img_path = (Path.cwd() / img_path).resolve()
image_hash = upload_image(account_id, img_path)
state["objects"].append(
{"type": "image", "hash": image_hash, "file": img_path.name}
)
print(f"[+] image: {img_path.name} -> {image_hash[:16]}…", file=sys.stderr)
# Ad set
adset_data = {
"name": a["name"],
"campaign_id": campaign_id,
"billing_event": a.get("billing_event", "LINK_CLICKS"),
"optimization_goal": a.get("optimization_goal", "LINK_CLICKS"),
"bid_strategy": a.get("bid_strategy", "LOWEST_COST_WITHOUT_CAP"),
"targeting": json.dumps(targeting),
"status": a.get("status", "PAUSED"),
"start_time": str(int(time.time()) + 3600),
}
if "daily_budget" in a:
adset_data["daily_budget"] = str(major_to_minor(a["daily_budget"], currency))
if "lifetime_budget" in a:
adset_data["lifetime_budget"] = str(major_to_minor(a["lifetime_budget"], currency))
adset_data["end_time"] = a["end_time"]
if "bid_amount" in a:
adset_data["bid_amount"] = str(major_to_minor(a["bid_amount"], currency))
adset = post(f"{account_id}/adsets", data=adset_data)
adset_id = adset["id"]
state["objects"].append({"type": "adset", "id": adset_id, "name": a["name"]})
print(f"[+] ad set: {adset_id} — {a['name']}", file=sys.stderr)
ads_created = []
for ad_cfg in a["ads"]:
creative_spec = {
"page_id": page_id,
"link_data": {
"link": landing_url,
"message": ad_cfg["message"],
"name": ad_cfg["headline"],
"image_hash": image_hash,
"call_to_action": {
"type": ad_cfg.get("cta", "LEARN_MORE"),
"value": {"link": landing_url},
},
},
}
if ad_cfg.get("description"):
creative_spec["link_data"]["description"] = ad_cfg["description"]
if ig_user_id:
creative_spec["instagram_user_id"] = ig_user_id
creative_payload = {
"name": f"Creative_{ad_cfg['name']}",
"object_story_spec": json.dumps(creative_spec),
}
# Opt-out of Meta auto-enhancements by default; enable via ad_cfg
if not ad_cfg.get("standard_enhancements", False):
creative_payload["degrees_of_freedom_spec"] = json.dumps(
{"creative_features_spec": {"standard_enhancements": {"enroll_status": "OPT_OUT"}}}
)
creative = post(f"{account_id}/adcreatives", data=creative_payload)
creative_id = creative["id"]
state["objects"].append(
{"type": "creative", "id": creative_id, "name": f"Creative_{ad_cfg['name']}"}
)
ad = post(
f"{account_id}/ads",
data={
"name": f"Ad_{ad_cfg['name']}",
"adset_id": adset_id,
"creative": json.dumps({"creative_id": creative_id}),
"status": ad_cfg.get("status", "PAUSED"),
},
)
ad_id = ad["id"]
state["objects"].append({"type": "ad", "id": ad_id, "name": f"Ad_{ad_cfg['name']}"})
ads_created.append({"ad_id": ad_id, "creative_id": creative_id, "name": ad_cfg["name"]})
print(f"[+] ad: {ad_id} — {ad_cfg['name']}", file=sys.stderr)
out_ad_sets.append(
{"adset_id": adset_id, "image_hash": image_hash, "ads": ads_created}
)
state["ad_sets"] = out_ad_sets
return state
# ─── Entry point ───────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Create a Meta ad campaign from a spec JSON",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--spec", required=True, help="Path to campaign spec JSON")
grp = parser.add_mutually_exclusive_group(required=True)
grp.add_argument("--dry-run", action="store_true", help="Plan only, no writes")
grp.add_argument("--confirm", action="store_true", help="Actually create everything")
parser.add_argument("--account-id", default=None, help="Override META_AD_ACCOUNT_ID")
parser.add_argument(
"--state-out",
default=None,
help="Where to write the state JSON (default: alongside spec with _state suffix)",
)
args = parser.parse_args()
spec_path = Path(args.spec).expanduser().resolve()
if not spec_path.exists():
print(f"spec not found: {spec_path}", file=sys.stderr)
sys.exit(2)
spec = json.loads(spec_path.read_text())
errs = validate_spec(spec)
if errs:
print("Spec validation failed:", file=sys.stderr)
for e in errs:
print(f" - {e}", file=sys.stderr)
sys.exit(2)
account_id = normalize_account_id(args.account_id)
if args.dry_run:
try:
p = plan(spec, account_id)
except MetaAPIError as e:
print_json({"ok": False, "error": str(e), "body": e.body})
sys.exit(1)
print_json(p)
return
# --confirm path
state_file = Path(args.state_out) if args.state_out else spec_path.with_name(
f"{spec_path.stem}_state_{int(time.time())}.json"
)
try:
state = execute(spec, account_id)
except Exception as e:
state = {"ok": False, "error": f"{type(e).__name__}: {e}"}
state_file.write_text(json.dumps(state, indent=2, ensure_ascii=False))
print_json(state)
raise
state_file.write_text(json.dumps(state, indent=2, ensure_ascii=False))
print(f"[state] -> {state_file}", file=sys.stderr)
print_json(state)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Detect creative fatigue at the ad level.
Creative fatigue = an ad's CTR is decaying because the audience has
seen it too many times. Signals to look for:
- Frequency > 3.0 (rule of thumb; varies by objective)
- CTR in the second half of the period < 70% of CTR in the first half
- CPM rising while CTR falls (Meta penalizes low engagement with worse delivery)
This script splits the requested date range in half and compares the
two halves per ad. Output is sorted by fatigue severity.
Usage:
python scripts/creative_fatigue.py --date-preset last_28d
python scripts/creative_fatigue.py --since 2026-03-01 --until 2026-04-15 \\
--frequency-threshold 2.5 --ctr-decay-threshold 0.7
"""
from __future__ import annotations
import argparse
import sys
from datetime import date, datetime, timedelta
from meta_client import MetaAPIError, normalize_account_id, paginate, print_json
def date_split(since: date, until: date) -> tuple[tuple[date, date], tuple[date, date]]:
"""Split a date range into two equal halves. Returns (first_half, second_half)."""
days = (until - since).days
if days < 4:
raise ValueError("Need at least 4 days of data to detect fatigue trend.")
mid = since + timedelta(days=days // 2)
return (since, mid), (mid + timedelta(days=1), until)
def resolve_dates(args) -> tuple[date, date]:
if args.since and args.until:
return (
datetime.strptime(args.since, "%Y-%m-%d").date(),
datetime.strptime(args.until, "%Y-%m-%d").date(),
)
today = date.today()
presets = {
"last_7d": 7,
"last_14d": 14,
"last_28d": 28,
"last_30d": 30,
}
days = presets.get(args.date_preset, 28)
until = today - timedelta(days=1) # yesterday
since = until - timedelta(days=days - 1)
return since, until
def fetch_period_insights(account: str, since: date, until: date) -> dict:
"""Return {ad_id: row} for the given period at ad level."""
params = {
"level": "ad",
"fields": ",".join(
[
"ad_id",
"ad_name",
"campaign_name",
"adset_name",
"spend",
"impressions",
"reach",
"frequency",
"clicks",
"ctr",
"cpm",
"cpc",
]
),
"time_range": f'{{"since":"{since.isoformat()}","until":"{until.isoformat()}"}}',
"limit": 100,
}
out = {}
for row in paginate(f"{account}/insights", params):
ad_id = row.get("ad_id")
if ad_id:
out[ad_id] = row
return out
def safe_float(v) -> float:
try:
return float(v)
except (TypeError, ValueError):
return 0.0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--account-id", help="Ad account ID. Defaults to env.")
parser.add_argument(
"--date-preset",
choices=["last_7d", "last_14d", "last_28d", "last_30d"],
default="last_28d",
)
parser.add_argument("--since", help="Custom range start (YYYY-MM-DD).")
parser.add_argument("--until", help="Custom range end (YYYY-MM-DD).")
parser.add_argument(
"--frequency-threshold",
type=float,
default=3.0,
help="Flag ads with frequency above this in the second half. Default 3.0.",
)
parser.add_argument(
"--ctr-decay-threshold",
type=float,
default=0.7,
help="Flag ads where second-half CTR < first-half CTR * this. Default 0.7 (i.e. 30%% drop).",
)
parser.add_argument(
"--min-spend",
type=float,
default=10.0,
help="Ignore ads that spent less than this in the second half (noise floor). Default 10.",
)
args = parser.parse_args()
account = normalize_account_id(args.account_id)
try:
since, until = resolve_dates(args)
first_range, second_range = date_split(since, until)
except ValueError as e:
print_json({"ok": False, "error": str(e)})
return 1
try:
first = fetch_period_insights(account, *first_range)
second = fetch_period_insights(account, *second_range)
except MetaAPIError as e:
print_json({"ok": False, "error": str(e), "meta_error": e.body.get("error")})
return 1
fatigued = []
healthy = []
for ad_id, late in second.items():
late_spend = safe_float(late.get("spend"))
if late_spend < args.min_spend:
continue
early = first.get(ad_id)
late_ctr = safe_float(late.get("ctr"))
late_freq = safe_float(late.get("frequency"))
late_cpm = safe_float(late.get("cpm"))
early_ctr = safe_float(early.get("ctr")) if early else None
early_cpm = safe_float(early.get("cpm")) if early else None
ctr_decay_ratio = (late_ctr / early_ctr) if early_ctr else None
cpm_change_ratio = (late_cpm / early_cpm) if early_cpm else None
flags = []
if late_freq >= args.frequency_threshold:
flags.append(f"frequency={late_freq:.2f} >= {args.frequency_threshold}")
if ctr_decay_ratio is not None and ctr_decay_ratio < args.ctr_decay_threshold:
flags.append(
f"CTR fell to {ctr_decay_ratio*100:.0f}% of first-half "
f"({early_ctr:.3f} → {late_ctr:.3f})"
)
if cpm_change_ratio is not None and cpm_change_ratio > 1.2 and ctr_decay_ratio and ctr_decay_ratio < 1:
flags.append(
f"CPM rose {(cpm_change_ratio-1)*100:.0f}% while CTR fell "
"(delivery quality penalty)"
)
record = {
"ad_id": ad_id,
"ad_name": late.get("ad_name"),
"campaign_name": late.get("campaign_name"),
"adset_name": late.get("adset_name"),
"first_half": {
"ctr": early_ctr,
"cpm": early_cpm,
"spend": safe_float(early.get("spend")) if early else 0,
},
"second_half": {
"ctr": late_ctr,
"cpm": late_cpm,
"frequency": late_freq,
"spend": late_spend,
},
"flags": flags,
# Severity = how many flags fired, weighted by spend (more spend = more urgent)
"severity_score": len(flags) * late_spend,
}
if flags:
fatigued.append(record)
else:
healthy.append(record)
fatigued.sort(key=lambda r: r["severity_score"], reverse=True)
print_json(
{
"ok": True,
"account_id": account,
"first_half": {"since": first_range[0].isoformat(), "until": first_range[1].isoformat()},
"second_half": {"since": second_range[0].isoformat(), "until": second_range[1].isoformat()},
"thresholds": {
"frequency": args.frequency_threshold,
"ctr_decay_ratio": args.ctr_decay_threshold,
"min_spend": args.min_spend,
},
"fatigued_count": len(fatigued),
"healthy_count": len(healthy),
"fatigued_ads": fatigued,
"healthy_ads_summary": [
{"ad_id": h["ad_id"], "ad_name": h["ad_name"], "ctr": h["second_half"]["ctr"]}
for h in healthy[:20]
],
}
)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""List campaigns under an ad account.
Usage:
python scripts/list_campaigns.py
python scripts/list_campaigns.py --account-id act_123 --status ACTIVE
python scripts/list_campaigns.py --status PAUSED ACTIVE
"""
from __future__ import annotations
import argparse
import json
import sys
from meta_client import MetaAPIError, normalize_account_id, paginate, print_json
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--account-id", help="Ad account ID (act_xxx). Defaults to env.")
parser.add_argument(
"--status",
nargs="+",
choices=["ACTIVE", "PAUSED", "ARCHIVED"],
default=["ACTIVE", "PAUSED"],
help=(
"Effective statuses to include. Default: ACTIVE PAUSED. "
"Note: DELETED is NOT supported by Meta's campaigns endpoint "
"(error 1815001). Use Ads Manager UI to see deleted campaigns."
),
)
parser.add_argument(
"--limit", type=int, default=200, help="Max campaigns to return. Default 200."
)
args = parser.parse_args()
account = normalize_account_id(args.account_id)
fields = ",".join(
[
"id",
"name",
"objective",
"status",
"effective_status",
"buying_type",
"daily_budget",
"lifetime_budget",
"budget_remaining",
"start_time",
"stop_time",
"created_time",
"updated_time",
"special_ad_categories",
]
)
params = {
"fields": fields,
"effective_status": json.dumps(args.status), # Meta expects a JSON array string
"limit": min(args.limit, 100), # Meta's max per page
}
campaigns = []
try:
for c in paginate(f"{account}/campaigns", params):
campaigns.append(c)
if len(campaigns) >= args.limit:
break
except MetaAPIError as e:
print_json({"ok": False, "error": str(e), "meta_error": e.body.get("error")})
return 1
# Convert budget fields from "minor units" (cents/agorot) to a friendly amount.
# Meta returns daily_budget="5000" meaning 5000 minor units, i.e. ₪50.00 / $50.00.
for c in campaigns:
for k in ("daily_budget", "lifetime_budget", "budget_remaining"):
if c.get(k):
try:
c[f"{k}_major"] = int(c[k]) / 100
except (ValueError, TypeError):
pass
print_json({"ok": True, "account_id": account, "count": len(campaigns), "campaigns": campaigns})
return 0
if __name__ == "__main__":
sys.exit(main())