# /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.


# Meta Lead Ads — Campaign Creator

## Triggers
- `/meta-lead-ads "ACME Agency" "https://docs.google.com/presentation/d/..."` — manual, full run
- `/meta-lead-ads` — prompts for client and Slides URL if not provided
- **ClickUp auto-trigger** when a task tagged `claude-code` matches:
  - `meta lead`, `lead ads meta`, `lead ads facebook`, `facebook lead`, `instagram lead`
  - `oglasi lead`, `lead oglas`, `lead ads`
  - Slides URL must be present in the task description

## 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.

| Slide | Type | Layout | Marker |
|-------|------|--------|--------|
| Single image ad | `SINGLE IMAGE` | Left: body + `Headline:` + `Description:`. Right: 1 image | `SINGLE IMAGE` on its own line |
| Video ad | `VIDEO` | Left: body + `Headline:` + `Description:`. Right: embedded Drive MP4 | `VIDEO` on its own line (optional — video element auto-detects) |
| Carousel ad | `CAROUSEL` | Left: 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 spec | auto | Text only — see format below | none 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:

| Label | Meaning |
|-------|---------|
| `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.

### Carousel card URL pairing

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:**
- Form description → `"Ispunite podatke ispod i naš tim će vas kontaktirati."`
- Thank you title → `"Hvala!"`
- Thank you body → `"Naš tim će Vas kontaktirati u roku od 24 sata."`
- Button text → `"Posjetite web stranicu"` + client website
- Contact fields → FULL_NAME, PHONE, EMAIL

**Also supported (since 2026-06-17 ACME Agency):**
- **Labeled form fields.** The form slide may use the same labels as the ad slides — `Headline:` (form title), `Description:` (form copy), `Questions:` (heading). The labels are stripped before parsing, so the title/description come through clean.
- **Thank-you screen on its OWN slide.** Mark it with `META LEAD FORM` (and a `Thank you screen` heading). All slides carrying an explicit `META LEAD FORM` / `INSTANT FORM` marker are collected in deck order and merged into one form, so the form spec and the thank-you screen can live on separate consecutive slides.
- **GHL autoresponder email on its own slide.** Mark it `THANK YOU EMAIL` (standalone line). That slide is classified UNKNOWN and ignored by Meta — it's the copy the operator pastes into GoHighLevel, not the Meta instant-form thank-you screen. Without the marker it used to score as a second LEAD_FORM slide (matches `/email/` + `/zahvalj/`) and silently overwrite the real form.

---

## 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>
```

- **`Copy (slide 10):`** / **`Copy (slides 3, 4, 5):`** — restrict to specific slides (parenthetical list, mirrors the targeting parser).
- **`Visuals: <folder>`** — pull creatives from a Drive folder, mapped to ad slides by **sorted filename in deck order** (default). Image slides consume images, video slides consume videos — a mixed folder (`1.png` + `video.mp4`) is routed by type automatically.
- **`Visuals (3.png): <folder>`** — pick the file **named `3.png`** for the (single) image slide instead of sorted order.
- **`Visuals (image_1, 4.png): <folder>`** — multiple named files in order: first name → first image slide, second name → second image slide, etc. Extension optional (`image_1` matches `image_1.png`). Missing name → clear error listing the folder's files.
- **Page is derived from the ad set, not `clients.json`.** The script reads the ad set's `promoted_object.page_id` and uses it (with the matching Instagram from existing ads). This is required for multi-page clients — see ACME Agency below. `verifyInjectAccess()` then validates token access to that derived page before any upload.

---

## Targeting Parsing Rules

| Field | What to write | Default |
|-------|---------------|---------|
| Budget | `15€ dnevno` / `20e daily` / `€20/day` | €15/day |
| Age | `30+` or `25-55` | 30–65 |
| Geo | `cijela Hrvatska` / `Croatia` / `HR` | Croatia |
| Audience Network | `bez 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
```bash
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).

| Flag | Purpose |
|------|---------|
| `--abo` | ABO 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,SI` | Override `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-intent` | Lead form: `<id>=true` (adds ACME Agencyw step before submit) |
| `--sms-verification` | Lead 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):**
```bash
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):**
- "Background image: use image from your ad" → toggle in Forms Library after creation
- "Flexible form delivery: Optimized" → toggle in Forms Library after creation

The script's Slack report flags these reminders when `--higher-intent` or `--sms-verification` is set.

---

## Key Files

| File | Purpose |
|------|---------|
| `ACME Agency/scripts/meta_lead_ads.mjs` | Main script — slides parser, targeting parser, Meta creation, reporting |
| `ACME Agency/scripts/lib/google_slides.mjs` | Google Slides API auth + utilities |
| `ACME Agency/scripts/clickup_executor.mjs` | Executor — `meta_lead_ads` handler at line ~145 |
| `ACME Agency/clients/clients.json` | Client registry — ad_account, page_id, instagram_id, website |

---

## Environment Requirements

| Env var | Used for |
|---------|----------|
| `META_ACCESS_TOKEN` | All Meta API calls |
| `GOOGLE_ADS_CLIENT_ID` | OAuth client ID (shared with Slides auth) |
| `<id>` | OAuth client secret |
| `<id>` | Slides API access (drive scope covers slides) |
| `CLICKUP_API_KEY` | ClickUp task updates |
| `SLACK_BOT_TOKEN` | Slack 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:
- `clients.json` is updated with the new form ID
- Summary posted to the client's Slack channel
- ClickUp task marked complete, `claude-code` tag removed

---

## 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:

- [ ] Campaign created with status `PAUSED` (NEVER `ACTIVE` from this skill)
- [ ] Campaign objective is `LEAD_GENERATION` (or `OUTCOME_LEADS`)
- [ ] Lead form created with a real `form_id`, locale `EN_US`, and the form fetches successfully via GET
- [ ] Every ad set is linked to the campaign AND has the form attached
- [ ] Every ad has creative attached (image OR video OR carousel — none missing)
- [ ] Targeting on each ad set is non-empty AND matches the targeting slide
- [ ] Daily budget set on each ad set, in correct currency
- [ ] No `reels` in `facebook_positions` (would have been stripped in preflight, but verify the response confirms it)
- [ ] No `14d_click` or `30d_click` in attribution
- [ ] Slack post via slack-reporter returned `ts` non-null with: campaign ID, form ID, ad count, link to Ads Manager
- [ ] If `--clickup-task <id>` passed: ClickUp task updated with completion comment + Ads Manager link
- [ ] Manifest saved at `ACME Agency/clients/<Client>/meta_lead_ads_<YYYY-MM-DD>.json` with all created object IDs (for cleanup if needed)

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

- Videos must be **Drive-hosted MP4** — YouTube embeds in slides are not supported
- Images are extracted directly from the slide's embedded content — quality depends on what was inserted into the slides
- If slide images are low-res, insert high-res versions into the slides before running
- Carousel card count is driven by the number of `Headline:` lines after the first (ad-level) headline. Extra images beyond that count are treated as logos or end-cards and dropped
- Explicit slide markers (`CAROUSEL` / `SINGLE IMAGE` / `VIDEO`) always win over heuristic classification. When a slide has many images but should be single/video, add the marker to force it
- The lead form locale is forced to `EN_US` (Meta does not support `hr_HR`)
- **Meta Lead Form briefs do NOT need a website/landing URL.** The Graph API requires an external creative link for LEAD_GENERATION ads (the Ads Manager UI hides this), so the script supplies it itself: `clients.json` `website` → else the Page's own `website` (`GET /{page_id}?fields=website`) → else it aborts asking you to set one. Never solicit a web link from the operator for a lead-form campaign.
- **Form resolution finds not-yet-attached forms.** After scanning existing campaign ads, `findLeadFormByName` falls back to listing the Page's `leadgen_forms` via a minted Page token — so a brand-new ad set + brand-new form launched together resolves without a freestyle script. Form resolution runs after page derivation so the fallback queries the correct (ad-set) page.
- `facebook_positions` does not accept `reels` (Meta API limitation)
- Every ad's body copy is reformatted through `formatAdCopyForMeta()` — see `ACME Agency/CLAUDE.md` → "Meta Ad Creation Rules"
- Dry-run the parser before creating ads:
  ```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)
```

- **Named visual files** — `Visuals (a.png, b.mp4): <folder>` cherry-picks exact files from a folder that holds many. Each name resolves against whichever pool it belongs to (image or video), in listed order, so one list drives both image and video slides. Without the parenthetical, each type pool is consumed in sorted filename order (the default). Use this whenever the wanted file isn't alphabetically first in its pool — otherwise the wrong asset attaches silently.
- **`Tip:` ad-type override** — `Tip: single image` / `Tip: video` / `Tip: carousel` (HR: `Tip: slika` / `karusel`, or `kao single image`) forces the ad type for the chosen slide(s), overriding the deck's auto-classification. Use when a slide is a VIDEO in the deck but you want a single-image ad from its copy + a static image (`Copy (slide 3)` + `Visuals (1.png)` + `Tip: single image`).

## Incidents

- **2026-06-19 — ACME Agency (parodontologija inject):** a carousel slide with one ad-level `Headline:` and no per-card headlines made `buildCarouselCards` fall back to literal card names `Card 1..N`, which `createAdCreative` sent as `child_attachments[].name` → "Card 1" printed under every card on the live ad. Fix: fallback cards now get `headline: ''`, and the CAROUSEL_AD branch omits `name`/`description` when blank (both optional on `link_data`). Carousels with real per-card headlines unchanged. Injected 3 ads (single `<id>`, carousel `<id>` — 5 cards, all `name:null`, video `<id>`) into ad set `ZG MW 30+ broad`, form `<id>`, all PAUSED.
- **2026-05-22 — ACME Agency (task 86c9t0u0a):** mixed image+video brief (2 image slides + 3 video slides) crashed twice. (a) `Visuals folder has 2 images but brief needs 5` — the visuals-folder check counted ALL ad slots but listed only image files; now uses `listDriveFolderAssets()` with separate per-pool count validation. (b) `Unexpected end of JSON input` on a 107MB video — single POST to `/advideos` returns empty body above ~100MB; now auto-routes >50MB through Meta's chunked upload session (`upload_phase=start|transfer|finish`).
- **2026-05-22 — ACME Agency:** new client needed ABO + Higher Intent + SMS verify + narrow placements + custom CTA/privacy that the script could not express. Added 14 CLI flags (see "Advanced CLI flags" above) so future clients with the same shape can be served without one-off scripts. Two UI-only toggles ("Background image from ad", "Flexible delivery Optimized") have no Graph API field — flagged in Slack report.
- **2026-05-27 — ACME Agency:** inject mode uploaded 3 videos (~90 MB total) before failing at `/adcreatives` with "You don't have required permission to access this profile". Root cause: ACME Agency's Page was a `client_page` in Paradox BM but the system user behind `META_ACCESS_TOKEN` had no Page-level task permission. Existing ads on the same campaign worked because they were created via Ads Manager UI (user token). Now caught by `verifyInjectAccess()` at Step 0 of both `runMetaLeadAdsInject` and `runMetaLeadAds` — the error message names the exact BM screen to fix and aborts before any upload.
- **2026-05-28 — ACME Agency:** SLO inject failed with "The page associated with this lead form is different with the page selected in Ad Set." ACME Agency runs two pages (German `<id>` "Bioklimatische Pergolen" + SLO `<id>` "ACME Agency"); `clients.json` recorded only the German one. Inject now derives the page (and matching IG) from the ad set's `promoted_object.page_id` rather than `clients.json`, so multi-page clients work automatically. The `clients.json` entry also gained a `pages` map for reference.
- **2026-05-28 — ACME Agency slide-10 inject:** two gaps fixed. (1) `Visuals (3.png):` wasn't parsed (regex required `Visuals:` with nothing between label and colon) — added the parenthetical form + named-file selection. (2) Per-type sequence naming (`Single Image 01`) existed only in uncommitted sandbox WIP and was lost on a git sync — restored `<id>()` + `buildAdName()` and wired into all three creation paths.
- **2026-05-29 — ACME Agency (SLO inject):** `findLeadFormByName` resolved forms only by scanning existing ads' `object_story_spec`, so a freshly-created form not yet on any ad ("ACME Agency - form 02 (Slovenija)") couldn't be found. Added a Page-token fallback (mint `GET /{page_id}?fields=access_token` → list `GET /{page_id}/leadgen_forms`). Form resolution also moved to AFTER page derivation so the fallback queries the correct (ad-set) page. `clients.json` ACME Agency gained `form_02_slovenija` (`<id>`).
- **2026-06-01 — ACME Agency Nekretnine (lead-form inject):** brand-new client with `website: null` → the creative link defaulted to `facebook.com` → Meta hard-rejected the ads ("Lead Generation Ads should always link to external content") AFTER a full video upload. Lead-form flows (inject + full-create) now fall back to the Page's own `website` for the creative link, and only abort if the Page has none either. Confirms the team norm: a "Meta Lead Form" brief never needs a web link from the operator.
- **2026-06-03 — ACME Agency (named videos):** the visuals matcher honored named files only for IMAGES; VIDEOS were always taken alphabetically, so `Visuals (…, Video_1.mp4)` silently attached the wrong video (`Blefaroplastika VIDEO.mp4` sorted first). Now both pools are narrowed by the named-file list — a name resolves into whichever pool it exists in. Also backfilled ACME Agency `website: https://go.ACME Agency.hr/` (its single-image ads need a real external link; see the ACME Agency fallback).
- **2026-06-03 — ACME Agency (`Tip:` override):** a brief paired `Copy (slide 3)` (a VIDEO slide in the deck) with `Visuals (1.png)` to build a single-image ad. The type-aware matcher demanded a video for slide 3, so it couldn't be served without a one-off script. Added the `Tip: single image|video|carousel` directive (parsed in `parseInjectTargets`, applied before the visuals block) to force the ad type.
- **2026-06-17 — ACME Agency (new client, full create):** the lead-form parse silently produced a near-empty form (only `EMAIL`, zero qualifying questions, wrong thank-you) because the deck split the form across 3 slides in a labeled format: slide 5 = form spec with `Headline:`/`Description:`/`Questions:` labels, slide 6 = thank-you screen, slide 7 = the GHL autoresponder email. Three root causes fixed: (1) the email slide (`THANK YOU EMAIL`) matched `/email/` + `/zahvalj/` → scored `LEAD_FORM` → overwrote slide 5; `classifySlide` now returns UNKNOWN for any slide with a standalone `THANK YOU EMAIL` marker. (2) the thank-you screen (slide 6) only hit 1 form-indicator → UNKNOWN → lost; an explicit `META LEAD FORM`/`INSTANT FORM` marker now forces `LEAD_FORM`, and the parse loop **collects ALL `LEAD_FORM` slides in deck order and merges them into one form** (so spec + thank-you screen on separate slides work). (3) `parseLeadFormSlide` now strips leading `Headline:`/`Description:`/`Questions:`/`Naslov:` labels and drops marker-only lines so they don't leak into the title/description. Files: `lib/google_slides.mjs` (`classifySlide`), `meta_lead_ads.mjs` (parse loop + `parseLeadFormSlide`). The script also has **no detailed-interest targeting** — the owner/business-interest layer (`flexible_spec`) was applied to the ad set by a post-create Graph `POST` (behavior `Small business owners` <id> + interests Entrepreneurship/Business networking/Business model). Campaign `<id>`, form `<id>`, all PAUSED.
