PORTAL / LIBRARY / meta-lead-ads

[ PAID MEDIA ]

/meta-lead-ads

Creates a complete Meta lead ads campaign from a Google Slides brief — reads copy, visuals, and lead form spec from the deck, uploads assets, builds the campaign in Meta (PAUSED), and reports to Slack and ClickUp.

Download the skill file (.md)

Placeholders like ACME Agency, <id> and you@example.com mark values that are per-agency — your install fills them with YOUR clients and accounts. If a section references a helper script you don't have yet, it ships with that workflow's install.

Meta Lead Ads — Campaign Creator

Triggers

What this skill does

Reads a Google Slides brief (one slide per ad format, one slide for the lead form), extracts all copy and visuals, uploads assets to Meta, creates a lead gen form, campaign, ad set, and ads — all in PAUSED status for ACME Agencyw. Posts a summary to the client's Slack channel and closes the ClickUp task.


Expected Google Slides Format

Each slide serves one purpose. Put an explicit type marker as a standalone line at the bottom of the left-column text box so the parser is unambiguous: CAROUSEL, SINGLE IMAGE, or VIDEO. Without the marker the parser falls back to image/video counting, which can guess wrong on edge cases.

SlideTypeLayoutMarker
Single image adSINGLE IMAGELeft: body + Headline: + Description:. Right: 1 imageSINGLE IMAGE on its own line
Video adVIDEOLeft: body + Headline: + Description:. Right: embedded Drive MP4VIDEO on its own line (optional — video element auto-detects)
Carousel adCAROUSELLeft: body + multiple Headline: lines (first is ad-level, rest are card names). Right: 1 image per card (extras are treated as logo / end card and dropped)CAROUSEL on its own line
Lead form specautoText only — see format belownone needed

Primary text box convention

The left-column text box is the source of truth for all copy. It's split into body lines and labeled fields. Labeled fields use Label: value on their own line, case-insensitive. Supported labels:

LabelMeaning
Headline: / Naslov: / Title:Ad headline. For carousels, multiple lines — first is ad-level, rest are card names in order
Description: / Opis:Ad description (single image / video) or per-card description (carousel, rarely used)
Link: / URL:Destination URL. For carousels, pair each Headline: with its own Link: directly below it

Anything without a label is body copy. The CAROUSEL / SINGLE IMAGE / VIDEO markers, any labeled field, and any bare URL are stripped from the body before it's sent to Meta — so you can edit the slide freely without worrying about marker text leaking into the ad.

There are two ways to attach a destination URL to each carousel card. Pick the most convenient:

A. Inline (preferred — deterministic). Put Link: lines directly below each card's Headline: in the primary text:

Headline: [ad-level title]

Headline: Card 1 name
Link: https://example.com/card-1

Headline: Card 2 name
Link: https://example.com/card-2

B. Separate URL text boxes (legacy). Drop bare URLs in their own text boxes anywhere on the slide. The parser matches each URL to a card by keyword similarity (URL slug tokens vs. headline tokens, with prefix matching so Croatian declension like "kostrena/kostreni" still matches). This works well when URL slugs reflect property/product names. If two cards don't share distinctive keywords with any URL, the parser falls back to reading order (top-to-bottom, left-to-right) and logs the pairing — always verify in the dry-run output.

If you don't provide any URLs, each card falls back to the client website.

Body copy formatting

Body text is automatically reformatted through formatAdCopyForMeta() before hitting Meta's API — see ACME Agency/scripts/lib/copy_formatter.mjs. You don't need to add blank lines by hand; the formatter will split long paragraphs into 3–5 short blocks. If you do add manual blank lines, the formatter respects them.

Lead form slide text format:

[Form headline — largest text]

[Form description paragraph]

[Custom question label?]
Option 1
Option 2
Option 3

Ime i prezime
Broj telefona
Email
Grad

[Thank you title]
[Thank you body text]

Defaults when fields are missing from slides:

Also supported (since 2026-06-17 ACME Agency):


ClickUp Task Format

Task name: Pustiti meta lead oglase za [service]
Tag: claude-code
Description:
  https://docs.google.com/presentation/d/PRESENTATION_ID/edit

  Budget: 15€ dnevno
  Targetujemo cijelu Hrvatsku
  Godine: 30+
  Placement: bez Audience Networka

The Slides URL must be in the task description. Budget, age, and geo are parsed automatically — if missing, defaults are used (€15/day, 30+, Croatia, no Audience Network).


Inject Mode (add ads to an existing campaign / ad set)

When the brief names an existing campaign + ad set + form, the script switches to INJECT mode — it does NOT create a campaign/ad set/form, just adds ads to what's there. Brief vocabulary:

u kampanju: ACME Agency - Leads / Meta ABO - maj 2026
u Ad Set: CRO MW 18-65 (Čiovo)
i poveži sa formom: ACME Agency - form 02
Copy (slide 10): https://docs.google.com/presentation/d/.../edit
Visuals (3.png): https://drive.google.com/drive/folders/<ID>

Targeting Parsing Rules

FieldWhat to writeDefault
Budget15€ dnevno / 20e daily / €20/day€15/day
Age30+ or 25-5530–65
Geocijela Hrvatska / Croatia / HRCroatia
Audience Networkbez Audience Networka (excluded by default)excluded

Preflight (run BEFORE creating any Meta campaign)

Meta lead ads create real campaigns in production accounts. Even though they're created PAUSED, validate everything before submission. A failed campaign creation mid-pipeline often leaves orphaned ad sets and forms that need manual cleanup in Meta.

  1. Client exists in clients.json AND has ad_account (required) AND page_id (required for lead forms).
  2. <id> set in .env and the page is in /me/accounts for that token. The script auto-runs verifyInjectAccess() at the start of every run (Step 0) to catch missing Page ADVERTISE task / inactive ad accounts BEFORE any slide parse or asset upload — error message names the exact BM screen to fix.
  3. Slides URL is a real Google Slides URL (docs.google.com/presentation/d/...) — reject any other shape.
  4. Slides accessible by the gws CLI / Slides API — read once before parsing to confirm permissions.
  5. Required slides present: copy slide, visual slide(s), targeting slide, lead form spec slide. If any missing → abort with which one.
  6. Lead form locale is EN_US (Meta does not support hr_HR). Force this — never accept other locales.
  7. facebook_positions does NOT include reels (Meta API limitation). If reels was specified → strip it and warn.
  8. Attribution windows are valid per CLAUDE.md ## AI Generation API Constraints: only 1d_click, 7d_click, 28d_click, 1d_view, 7d_view. Reject 14d_click and 30d_click.
  9. Budget is in account currency AND ≥ Meta minimum daily (€1 EUR / $1 USD typically).
  10. Targeting is non-empty — at minimum: countries, age range, gender. Reject empty targeting (Meta will create but it won't deliver).
  11. All embedded slide images are extractable AND ≥ 600 px on the longest side (Meta rejects tiny images).
  12. Videos (if any) are Drive-hosted MP4 (NOT YouTube embeds — those silently fail).
  13. ClickUp task ID present if --clickup-task was passed (for posting completion update).

If all checks pass, log "preflight: OK (n ads, m ad sets, lead form locale=EN_US, budget=...)" and proceed.


How to Run

Via ClickUp (automatic)

  1. Create a ClickUp task in the client's list
  2. Paste the Google Slides URL in the description
  3. Add budget, targeting instructions in plain text
  4. Tag the task claude-code
  5. Run: node ACME Agency/scripts/clickup_executor.mjs --execute

Via slash command (manual)

/meta-lead-ads "ACME Agency" "https://docs.google.com/presentation/d/..."

Claude Code will detect the intent and run the skill with the provided arguments.

Direct CLI

node ACME Agency/scripts/meta_lead_ads.mjs --client "ACME Agency" --slides "https://docs.google.com/presentation/d/..."

Advanced CLI flags (optional)

All flags are opt-in — defaults preserve the historical CBO + narrow placements + Higher Volume + auto-generated names behavior. Use them when the brief asks for something the parser can't infer (most often: ABO budget, Higher Intent forms, SMS verification, narrow placement exclusion).

FlagPurpose
--aboABO budget mode — daily_budget moves from campaign to ad set
--budget-eur <n>Override daily budget (EUR)
--campaign-name "<s>"Override auto-generated campaign name
--adset-name "<s>"Override auto-generated ad set name
--geo HR,SIOverride targeting.geo_locations.countries
--age-min <n> / --age-max <n>Override targeting age range
--placements-exclude <list>Switch to broad placements, exclude listed tokens. Valid: fb_notifications, fb_marketplace, fb_right_column, ig_explore_home, search
--higher-intentLead form: <id>=true (adds ACME Agencyw step before submit)
--sms-verificationLead form: <id>=true (SMS OTP). Separate Graph API field — can combine with --higher-intent
--privacy-url <url>Override privacy policy URL
--privacy-text "<s>"Override privacy policy link text
--cta-action <action>Thank-you button_type (default VIEW_WEBSITE)
--cta-url <url>Thank-you website_url
--cta-text "<s>"Thank-you button_text
`--status PAUSED\ACTIVE`Campaign/ad set/ad status (default PAUSED)

Example — ACME Agency 2026-05-22 (ABO + Higher Intent + SMS + narrow placements):

node ACME Agency/scripts/meta_lead_ads.mjs --client "ACME Agency" \
  --slides "https://docs.google.com/presentation/d/..." \
  --campaign-name "ACME Agency - Lead / Forms ABO - maj 2026" \
  --adset-name "CRO 30+" \
  --abo --budget-eur 25 \
  --geo HR --age-min 30 --age-max 65 \
  --placements-exclude fb_notifications,fb_marketplace,fb_right_column,ig_explore_home,search \
  --higher-intent --sms-verification \
  --privacy-url "https://ACME Agency.com/o-nama/politika-privatnosti" \
  --privacy-text "Visit ACME Agency's Privacy Policy." \
  --cta-action VIEW_WEBSITE \
  --cta-url "https://ACME Agency.com/" \
  --cta-text "Posjetite naš website"

Two UI-only toggles (no Graph API field as of v20):

The script's Slack report flags these reminders when --higher-intent or --sms-verification is set.


Key Files

FilePurpose
ACME Agency/scripts/meta_lead_ads.mjsMain script — slides parser, targeting parser, Meta creation, reporting
ACME Agency/scripts/lib/google_slides.mjsGoogle Slides API auth + utilities
ACME Agency/scripts/clickup_executor.mjsExecutor — meta_lead_ads handler at line ~145
ACME Agency/clients/clients.jsonClient registry — ad_account, page_id, instagram_id, website

Environment Requirements

Env varUsed for
META_ACCESS_TOKENAll Meta API calls
GOOGLE_ADS_CLIENT_IDOAuth client ID (shared with Slides auth)
<id>OAuth client secret
<id>Slides API access (drive scope covers slides)
CLICKUP_API_KEYClickUp task updates
SLACK_BOT_TOKENSlack reporting

Google Slides API must be enabled in Cloud Console project agency-os-mcp-490220. To enable: https://console.cloud.google.com/apis/library/slides.googleapis.com?project=agency-os-mcp-490220


What Gets Created in Meta

All objects are created in PAUSED status — nothing goes live automatically.

  1. Lead gen form — with parsed headline, description, custom questions, contact fields, thank you screen
  2. Campaign — OUTCOME_LEADS objective, CBO with parsed budget
  3. Ad set — ON_AD destination, LEAD_GENERATION optimization, parsed targeting (no Audience Network)
  4. Ads — one per slide: single image, video, and/or carousel (SIGN_UP CTA → lead form)

After creation:


After the Skill Runs

  1. Activate in Ads Manager — ACME Agencyw creatives and targeting, then turn on
  2. Connect form to GHL — in GHL, go to Integrations → Facebook Lead Ads → select the form by ID
  3. GHL automation triggers on new lead submission

Verification (run AFTER campaign creation — confirm everything wired correctly)

Lead ads have many moving parts and silent failures leave orphaned objects in Meta. Check ALL of these before declaring done:

If any check fails, name the gap explicitly AND list the orphaned Meta object IDs that need manual cleanup. Never claim success when verification fails — orphaned ad sets in Meta are real money waiting to drain.


Important Notes

``bash node -e "import('./ACME Agency/scripts/meta_lead_ads.mjs').then(async m => { const r = await m.parsePresentation('PRESENTATION_ID', { skipImageDownload: true }); console.log(JSON.stringify(r, null, 2)); })" ``


Inject-mode brief (adding ads to an existing campaign)

Inject mode adds new ads into an existing campaign + ad set + form — it does NOT create a campaign. Trigger lines (natural phrasing tolerated):

Dodaj Ads u kampanju: <Campaign Name>          ← required (also "u kampanju:" / "Campaign:")
u Ad set: <Ad Set Name>                         ← required (also "Ad set name:" / "in Ad Set:")
Forma: <Form Name>                              ← OR "Form:" / "Lead Form name:" / "poveži sa formom:"
                                                   (or "Website URL: <url>" for a web-traffic ad)
Copy (slide 3) / Copy (slides 1, 2): <Slides URL>   ← which deck slides to pull copy from
Visuals (1.png, Video_1.mp4): <Drive folder URL>    ← see named-files below
Tip: single image                              ← optional ad-type override (see below)

Incidents