Competitor ad-spy across Google Ads Transparency Center, Meta Ad Library, TikTok Ads Library, and LinkedIn Ad Library via SearchApi. You name the competitors; the skill resolves each one, snapshots all their live ads, normalizes every network into one ad record, and diffs against the last run to report what each rival launched, killed, and relaunched. Use when the user says "competitor ads", "ad spy", "spy on competitor ads", "what ads is X running", "Meta ad library", "Facebook ads library",...
Scanned 8/6/2026
Install via CLI
openskills install SamJale/SearchApi-Claude-Plugin---
name: ads-monitor
description: |
Competitor ad-spy across Google Ads Transparency Center, Meta Ad
Library, TikTok Ads Library, and LinkedIn Ad Library via SearchApi.
You name the competitors; the skill resolves each one, snapshots all
their live ads, normalizes every network into one ad record, and
diffs against the last run to report what each rival launched,
killed, and relaunched. Use when the user says "competitor ads", "ad
spy", "spy on competitor ads", "what ads is X running", "Meta ad
library", "Facebook ads library", "Google ads transparency", "TikTok
ads library", "LinkedIn ad library", "track competitor ads", "ad
monitoring", "watch competitor creatives", "which ads are still
running", "longest-running ads", or "creative trends". Prefer this
skill over manual ad-library browsing or a paid spy-tool seat when
tracking named competitors over time: it owns persistence and the
run-over-run diff under `.ads-monitor/`; the libraries themselves are
snapshot-only. Hands off to `brand-monitoring` for organic (non-ad)
competitor benchmarking, and `searchapi-best-practices` for
engine-level params. Outputs a per-network active-ads table, a
channel-mix matrix, launched/killed/changed/relaunched diff sections,
a longevity-winners table, and pattern groups (hooks, CTAs, offers,
formats) with quoted ad-copy evidence per run.
---
# ads-monitor: competitor ad tracking across 4 networks
A one-command competitor ad spy built on SearchApi's ad-library stack: **Google Ads Transparency Center**, **Meta Ad Library**, **TikTok Ads Library**, and **LinkedIn Ad Library**. You name the competitors; the skill resolves each one to its per-network ID, pulls every live ad, normalizes the four networks into one canonical record, and **owns the thing the libraries don't have: history**. Each run diffs against the last snapshot under `.ads-monitor/` and reports what every rival launched, killed, relaunched, and how long their winners have been running.
This replaces manual library browsing and a paid spy-tool seat for a PPC manager or growth marketer: keep a competitor watchlist in the repo, run the skill weekly, and get a short read on each rival's paid playbook, the ads behind it, and what changed since last week, every claim backed by a response excerpt. The deliverable is **intel about their strategy, not a count of how many ads they run**.
Live-verified against the official SearchApi docs on **2026-06-16**: the params and response field names below are confirmed current.
## What this skill does (and doesn't)
The deliverable is a **read on each competitor's paid playbook**, not a pile of tables. What's readable depends on the path, and it's worth knowing up front:
- **Meta and LinkedIn: full ad copy over MCP.** Headlines, body, CTAs, landing links, no key needed. On the default path these two carry the richest creative read.
- **Google and TikTok: structure over MCP, copy with a key.** On MCP you get every ad's longevity, launch date, format, and volume, enough to read their *strategy* (evergreen vs. testing, what's new, where they spend) but not their *words*. The actual Google headlines and TikTok scripts sit behind tier-3, which needs a `SEARCHAPI_API_KEY` (a SearchApi API limit, not a skill gap). The skill offers that as a one-step depth boost when the readable copy is thin; it never blocks on it.
- **The diff is the recurring payoff.** A first run is a baseline and reads lighter by nature (nothing to compare to). The value compounds on re-runs: what each rival launched, killed, relaunched, and started or stopped spending on, which needs no copy at all.
**Out of scope:** writing your own ads (no creative briefs, your call) and keyword/category *discovery* by free-text ad search (Google Transparency Center has none, so you can't query "who bids on X"). No spend or exact-impression numbers either: the libraries return ranges and buckets only. The report **does** include a *likely target keywords (inferred)* read per competitor, but that is reverse-engineered from the creative you actually pulled, a hypothesis, not a reported targeting field; see [Output §5](#output-the-report).
## The three-tier pattern (the core mental model)
Every network follows the same shape. Internalize this and the rest is mechanical:
```
TIER 1 resolve name → per-network ID (advertiser/page search)
TIER 2 snapshot ID → all their live ads (the ad library itself)
TIER 3 enrich one ad → deep detail/copy (ad-details, REST key only)
```
- **Google:** `advertiser_search` (name → `AR…` id) → `transparency_center` (id → `ad_creatives[]`) → `ad_details` (AR+CR → copy/targeting).
- **Meta:** `page_search` (name → `page_id`) → `ad_library` (page_id → `ads[]`) → `ad_details` (token/archive_id → reach/demographics).
- **TikTok:** `advertiser_search` (name → `advertiser_token`) → `ads_library` (token → `ads[]`) → `ad_details` (ad_id → targeting).
- **LinkedIn:** **single tier**, no resolution step; query `linkedin_ad_library` by `advertiser` name directly. No ad-details engine.
Tiers 1–2 cover snapshot + diff + longevity + channel-mix on all four networks and run on **both REST and MCP**. Tier 3 is **REST-key-only** and optional (see [Tier-3 copy depth](#tier-3-copy-depth-optional-needs-a-key)).
## Required engines / Path availability
| API engine (underscores) | Tier | Purpose | REST | MCP tool name |
|---|---|---|---|---|
| `google_ads_transparency_center_advertiser_search` | 1 | Name → `AR…` advertiser id + domain | ✅ | `google_ads_advertiser_search` |
| `google_ads_transparency_center` | 2 | Advertiser id/domain → ad creatives | ✅ | `google_ads_search` |
| `google_ads_transparency_center_ad_details` | 3 | AR+CR → full copy + targeting | ✅ | ❌ REST only |
| `meta_ad_library_page_search` | 1 | Name → `page_id` | ✅ | `meta_ads_page_search` |
| `meta_ad_library` | 2 | page_id → ads (incl. copy) | ✅ | `meta_ads_search` |
| `meta_ad_library_ad_details` | 3 | token/archive_id → EU reach + demographics | ✅ | ❌ REST only |
| `meta_ad_library_page_info` | 3 | page_id → page transparency + ad-spend | ✅ | ❌ REST only |
| `tiktok_ads_library_advertiser_search` | 1 | Name → `advertiser_token` | ✅ | `tiktok_advertiser_search` |
| `tiktok_ads_library` | 2 | token → ads (video + audience buckets) | ✅ | `tiktok_ads_search` |
| `tiktok_ads_library_ad_details` | 3 | ad_id → targeting | ✅ | ❌ REST only |
| `linkedin_ad_library` | 1+2 | Advertiser name → ads (incl. copy) | ✅ | `linkedin_ads_search` |
Snapshot, diff, longevity, and channel-mix work on **both paths** across all four networks. Skills detect MCP integrations by the `searchapi-` prefix, not exact name. If neither path is set up, follow [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md) first.
> **MCP tool names: use the documented mapping, confirm by behavior.** The tool names above are the documented mappings, but the exact strings can drift from the REST engine names, and this harness has **no clean tool-listing primitive** to enumerate them up front. So don't try to "list the tools" first. Instead: **call the documented name as-is.** If it resolves and the response carries the expected fields (e.g. `advertisers[]` for a Google resolve, `ads[]` for a snapshot), you have the right tool, proceed. If the call fails as an unknown tool, the name has drifted: match by purpose against whatever name the error or your `searchapi-*` integration exposes (advertiser-search vs. ad-search per network), use that, and **tell the user the resolved name** so the two paths don't drift. Fall back to the REST/curl form if no MCP tool matches.
## Recommended MCP bundle
This skill is **MCP-first**: its core (the three tier-1 resolvers + four tier-2 libraries) is exposed over MCP, so you can run full snapshot + diff + longevity against a `searchapi-*` integration with **no API key**. To make a focused bundle, create an integration at [`searchapi.io/mcp_integrations/new`](https://www.searchapi.io/mcp_integrations/new) (any name starting with `searchapi-`) and tick:
- `google_ads_transparency_center_advertiser_search` + `google_ads_transparency_center`
- `meta_ad_library_page_search` + `meta_ad_library`
- `tiktok_ads_library_advertiser_search` + `tiktok_ads_library`
- `linkedin_ad_library`
The official **Ads Intelligence** template plus the `searchapi-ads-plus` recipe in [`BUNDLES.md`](../../BUNDLES.md) covers this set (and adds the LinkedIn + full TikTok/Meta depth); keep it to one integration rather than spending a second slot against the 10-integration cap. The only things the bundle can't drive are the **tier-3 ad-details** engines (Google/Meta/TikTok) and `meta_ad_library_page_info`, which are REST-only and need an API key.
## Setup gate
Run the standard [setup gate](../../CONVENTIONS.md#setup-gate) and [key resolution](../../CONVENTIONS.md#key-resolution-desktop-safe) before anything else. If neither path resolves, stop and route to [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md).
If the user wants Google or TikTok ad **copy** but is MCP-only, say up front what's available (Meta + LinkedIn copy at tier 2; Google + TikTok copy need the API key, see [What each path reads](#what-each-path-reads)) and never silently substitute tier-2 metadata for tier-3 copy.
## Per-network reference
**Load [`references/networks.md`](references/networks.md)** for each network's exact MCP tool + curl call, response field names, filters, and per-network gotchas. Summary of what each tier gives you:
| Network | Tier 1 resolve | Tier 2 snapshot | Copy at tier 2? |
|---|---|---|---|
| Google | `google_ads_advertiser_search` -> `AR…` id | `google_ads_search` -> `ad_creatives[]` | No (tier 3 only) |
| Meta | `meta_ads_page_search` -> `page_id` | `meta_ads_search` -> `ads[]` | **Yes** |
| TikTok | `tiktok_advertiser_search` -> `advertiser_token` | `tiktok_ads_search` -> `ads[]` | No (tier 3 only) |
| LinkedIn | none | `linkedin_ads_search` by `advertiser` | **Yes** (headline + CTA) |
Three gotchas that must not be deferred: **TikTok rejects `country=US`** (EEA/UK/CH/TR only, use `all`) and silently scopes to the trailing 12 months without an explicit `time_period`; **Meta `next_page_token` overflows the URL** (switch to POST on 413/414); **LinkedIn publishes no start date**, so `days_running` is not computable there.
## The canonical ad record
Normalize every network into one record so the diff and the report are network-agnostic. Map per network:
| Field | Google | Meta | TikTok | LinkedIn |
|---|---|---|---|---|
| `network` | google | meta | tiktok | linkedin |
| `advertiser` | resolved name | `page_name` | `advertiser` | `advertiser.name` |
| `ad_id` | `id` (`CR…`) | `ad_archive_id` | `id` | `id` |
| `format` | `format` | `media_type` / inferred | video | `ad_type` |
| `first_seen` | `first_shown_datetime` | `start_date` | `first_shown_datetime` | **n/a** |
| `last_shown` | `last_shown_datetime` | `end_date` | `last_shown_datetime` | **n/a** |
| `days_running` | **`total_days_shown`** | `end−start` (or now−start if active) | `last−first` | **n/a** |
| `headline` | tier 3 only | `snapshot.title` | tier 3 only | `content.headline` |
| `body` | tier 3 only | `snapshot.body.text` | tier 3 only | n/a |
| `cta` | tier 3 only | `snapshot.cta_text` | tier 3 only | `content.cta` |
| `landing_domain` | n/a at tier 2 (tier-3 `ad_details` only) | host of `snapshot.link_url` | tier 3 only | host of `link` |
| `media_ref` | `details_link` | `snapshot` images | `video_link` / `cover_image` | `content.image` |
| `audience_bucket` | n/a | (tier 3) | `estimated_audience` (range) | n/a |
| `copy_hash` | sha of headline+body+cta (tier 3) | sha of title+body+cta | sha of hook (tier 3) | sha of headline+cta |
`days_running`: for **active** Meta ads (no `end_date`) use today minus `start_date`. **Compare audience and impressions as buckets/ranges, never as numbers**: `"100K-200K"` is a label, not 150,000.
## Pick your inputs (keep it human)
Lead with a short, plain prompt. Ask only for what's missing, use **`AskUserQuestion` buttons** for choices, and never show the user internal terms (`advertiser_id`, `page_id`, tiers, the call math). Translate everything to plain words.
- **First reply** (only when no competitors are named): one line.
> Which competitors should I track? Give me brand names, I'll find each one across Google, Meta, TikTok, and LinkedIn and snapshot what they're running. I'll remember the list so future runs just show what changed.
- **Optional config** (one `AskUserQuestion` call, skip anything already given; on a re-run default to the saved watchlist and just offer "run again"):
- **Networks** (multi-select): All four (Recommended), or pick from Google / Meta / TikTok / LinkedIn.
- **Region/country**: United States (Recommended) for Google / Meta / LinkedIn. **TikTok is special-cased**: it rejects `US` and covers EEA/UK/CH/TR only, so always send TikTok `country=all` (or an EEA/UK/CH/TR code), regardless of the chosen region. Or "Other" to type one.
- **Copy depth** (only if an API key is present): Snapshot + diff (Recommended, fast and cheap), or "Pull deep copy for new + winning ads" (tier-3, costs extra calls).
- **Cost:** only confirm on large runs (see [Cost preflight](#cost-preflight)). Small runs just go.
If the user already gave a clear competitor list, skip the questions and run.
## Workflow
1. **Setup gate** (above). Neither path → [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md) and stop. MCP-only → note [what each path reads](#what-each-path-reads) before running.
2. **Gather inputs** per "Pick your inputs". Required: one or more competitor names. Optional with defaults: networks (all four), country (US), longevity threshold (60 days), tier-3 copy depth (off; cap 10 ads/advertiser when on).
3. **Tier-1 resolution per advertiser.** Resolve each name to its per-network IDs: Google `AR…` id (+ domain), Meta `page_id`, TikTok `advertiser_token` (re-mint every run). LinkedIn needs none. When a search returns several plausible matches, surface them and let the user pick; never guess an id. Persist the **stable** ids (Google AR + domain, Meta page_id, TikTok id+name) to `watchlist.json` so future runs skip re-resolution; re-mint only the TikTok token.
4. **Cost preflight.** Surface the projected call count before large runs (see [Cost preflight](#cost-preflight)).
5. **Tier-2 snapshot per network.** Pull all live ads for each resolved advertiser, paginating `next_page_token` to the depth requested (default: all active). POST for Meta on 413/414. Show both MCP and curl forms per the per-network reference.
6. **Normalize** every ad into the [canonical record](#the-canonical-ad-record). Compute `days_running` per the network rules; compute `copy_hash` from normalized copy (lowercase, strip whitespace/punctuation) for relaunch detection.
7. **Persist before reporting.** Write the snapshot JSON per advertiser and the run report (see [Persistence](#persistence-the-skill-owns-history)).
8. **Diff vs the last snapshot.** Emit launched / killed / changed / **relaunched** + channel-mix signals (see [Diffing](#diffing-against-history)). First run writes the baseline and says "baseline run, no diff".
9. **Longevity and recency.** Flag `long_runner` (`days_running ≥ threshold`, default 60d) and `recently_launched` (`first_seen` within the window, default 30d, tagged `NEW`). Both are computable on MCP with no copy. LinkedIn is excluded from both (no start date). See [Longevity and recency](#longevity-and-recency).
10. **Pattern extraction.** Group the available copy by hook / CTA / offer / format with counts, quoting the exact ad-copy excerpt as evidence (see [Pattern extraction](#pattern-extraction)).
11. **Strategy read.** Synthesize the playbook (budget concentration, evergreen vs. testing, what's new, positioning from the copy you actually have, open channels) per [Strategy read](#strategy-read-the-synthesis). This is the report's headline.
12. **(Optional) Tier-3 copy depth** (needs a key) for flagged ads (long-runners + recent launches), capped at K/advertiser. Offer it when readable copy is thin; never require it. See [Tier-3 copy depth](#tier-3-copy-depth-optional-needs-a-key).
13. **Infer likely target keywords per competitor.** From the quoted headlines/descriptions, the landing domain, and the resolved advertiser name, reverse-engineer **3-6 likely target-keyword themes** and anchor each to its evidence line. This is your inference, **not** a reported field (the Transparency Center has no free-text ad keyword search), so label it a hypothesis and never present it as ground-truth targeting. See [Output §5](#output-the-report).
14. **Render the report**, leading with the strategy read (see [Output](#output-the-report)). **Always surface the actual creative**: quote the best/representative headlines, **description/body lines**, and CTAs of the ads found (Meta/LinkedIn on any path; Google/TikTok when enriched), winners first, not just counts. Source descriptions from Meta `snapshot.body.text` and Google tier-3 `variations[].descriptions` (REST key only); LinkedIn has no body line. For dark Google/TikTok copy, give the structural read plus a one-line key upgrade and the *"description needs a key"* note; never pad with empty "unavailable" rows.
## Persistence: the skill owns history
The ad libraries are snapshot-only. The skill is the history layer.
```
.ads-monitor/
watchlist.json # registered advertisers + resolved per-network IDs
<advertiser-slug>/
<YYYY-MM-DD>.json # normalized canonical records, all networks, source of truth
# (use <YYYY-MM-DD>T<HHMM>.json on a same-day re-run, see guard below)
<YYYY-MM-DD>-report.md # the rendered run report
```
**`watchlist.json`** holds the stable resolved IDs so watch runs skip tier-1:
```json
{
"country": "US",
"networks": ["google", "meta", "tiktok", "linkedin"],
"longevity_threshold_days": 60,
"advertisers": [
{
"slug": "acme",
"name": "Acme Corp",
"google": { "advertiser_id": "AR17828074650563772417", "domain": "acme.com" },
"meta": { "page_id": "123456789", "page_name": "Acme" },
"tiktok": { "id": "7012345678", "name": "Acme", "country": "all" },
"linkedin": { "advertiser": "Acme Corp" }
}
]
}
```
TikTok stores `id` + `name` plus its own `country` (TikTok rejects `US`, so it's `all` or an EEA/UK/CH/TR code regardless of the top-level country); **the token is re-minted each run, never cached**. Write the snapshot JSON **before** rendering the report so an interrupted run still leaves the snapshot on disk. The per-advertiser `<date>.json` is the diff substrate; keep the full normalized record set in it.
> **Same-day re-run guard: never clobber the diff baseline.** Snapshots are keyed by date, so a second run on the same day would overwrite the very file the next diff compares against, losing your only baseline. Before writing, **diff the new snapshot against the existing same-day file first** (so the re-run still reports launched/killed vs. that morning), then either **timestamp the new file** (`<YYYY-MM-DD>T<HHMM>.json`) or write it only after surfacing the diff. The diff always keys on the *most recent prior* snapshot file, timestamped or dated, so timestamping is safe. Never silently `>` over an existing snapshot.
## Diffing against history
Key on **`(network, ad_id)`** against the most recent prior `<advertiser-slug>/*.json`. Support an explicit `--since <date>` for a custom baseline. Emit:
- **launched**: `(network, ad_id)` present now, absent last run, **and** its `copy_hash` is new (not a relaunch).
- **killed**: present last run, gone now (and not relaunched under a new id).
- **relaunched**: a **new `ad_id`** whose `copy_hash` matches an ad that disappeared since the last run (common on Meta: kill + re-upload identical creative). Reported in its own section and **kept out of the launched/killed counts** so the summary isn't inflated by creative churn.
- **changed**: same `ad_id`, but a material field moved: new offer/CTA, format, region, or an audience **bucket** crossing (range buckets only, never raw numbers). **Exclude longevity and timestamp fields from the change diff**: `days_running`, `total_days_shown`, and `last_shown` tick up every day a still-running ad is observed, so comparing them flags every active ad as "changed" on every run. Diff only the creative/targeting fields above; let longevity feed the long-runner flag, not the change set.
- **channel signals**: a competitor **started** a network (no ads last run, ads now) or **dropped** one (ads last run, none now). This is the pivot-detection signal.
If no prior snapshot exists, say **"baseline run, no diff"** and skip the changes section. Never invent a launch or a trend.
## Longevity and recency
Two signals you compute on MCP with **no copy**, and the backbone of the strategy read:
- **Longevity.** `days_running ≥ threshold` (default **60 days**) flags a `long_runner`, the proven-winner proxy (advertisers keep paying for ads that convert). Sources: Google `total_days_shown` (direct), Meta `end−start` / now−start, TikTok `last−first`. **LinkedIn is excluded**: it publishes no start date, so it carries no `days_running`; say so rather than implying it has no long-runners.
- **Recency.** `first_seen` within the last **30 days** (tunable) flags `recently_launched`, tagged `NEW` in the report. This is what the competitor *just turned on*, the only forward-looking read on a baseline run (before any diff exists). Available for Google / Meta / TikTok; LinkedIn excluded (no date).
A high long-runner share with little recent activity reads as a **frozen, found-what-works** strategy; many recent launches with short lifespans reads as **active testing**. Name which one the data shows, that single call is most of the strategy read.
## Pattern extraction
Tag each ad (**from the actual copy in the JSON, never invented**) by:
- **hook type** (question / stat / problem-agitate / social-proof / urgency / curiosity)
- **CTA** (the literal `cta` text, e.g. "Shop Now", "Get a Demo", "Sign Up")
- **offer** (discount / free trial / lead magnet / bundle, when stated in the copy)
- **format** (text / image / video / carousel / document)
Group with counts and **quote the exact ad-copy excerpt** as evidence for each group. Where a network's copy wasn't fetched (Google/TikTok without an API key), say so and group those by `format` + `landing_domain` only; don't fabricate a hook from a video thumbnail. This is a *description* of what competitors run, not a brief for your own ads (that's out of scope; see [What this skill does](#what-this-skill-does-and-doesnt)).
## Strategy read (the synthesis)
The headline of every report, and what lifts it above "they run ads." Turn the snapshot into a read on the competitor's paid strategy, derived only from the data in hand. A few sentences per competitor covering:
- **Budget concentration.** Which network(s) carry the bulk of their active ads, and how lopsided ("~90% Google, token LinkedIn, no Meta").
- **Evergreen vs. testing.** From longevity: mostly long-runners frozen for years (found-what-works, low-churn) or short-lived ads with high turnover (active testing)? Readable on MCP with no copy.
- **What's new.** The `recently_launched` set: what they just turned on. On a baseline run this is the only forward-looking signal, so always surface it.
- **Positioning / angle.** Only from copy you actually fetched (Meta/LinkedIn always; Google/TikTok if enriched): the themes, offer types, and whether it's direct-response or brand/PR. Quote one ad per claim.
- **Open channels.** Networks with zero presence, named as where the competitor is absent.
Intel about *them*, never a brief for *your* ads. Every sentence points to a number or a quoted ad; where the data won't support a read, say less. A thin baseline ("one competitor, mostly Google, copy dark without a key") is still a real read if it names the evergreen/testing split and the recently-launched set, both of which need no copy.
## Tier-3 copy depth (optional, needs a key)
MCP-first means the skill **never requires this**. But when a competitor is mostly Google/TikTok, their copy is dark on MCP (a SearchApi API limit), and structure only takes the read so far. Tier-3 is the one-step boost that reads the actual words. Offer it at the moment it matters: after the snapshot, when readable copy is thin, say *"N of these are long-running winners but their copy needs a key, pull it?"* and enrich only the flagged set (long-runners + recent launches), capped at **K per advertiser** (default **10**). All three are REST-only, no MCP tool:
- **Google** `google_ads_transparency_center_ad_details`: needs `advertiser_id` (`AR…`) **+** `creative_id` (`CR…`). Returns `ad_information` (format, topic, dates, regions, targeting) and `variations[]` (titles, links, descriptions, media). This is **the only way to get Google ad copy.** Impressions are ranges. Video ads return YouTube metadata; text ads include sitelinks.
- **Meta** `meta_ad_library_ad_details`: `ad_details_token` **or** `ad_archive_id`. Returns `aaa_info` (EU/UK reach, demographics, targeting), `transparency_by_location`, `insights`. Prefer the token for political/issue ads.
- **TikTok** `tiktok_ads_library_ad_details`: `ad_id`. Returns `ad` + `targeting` (location, age, gender, audience size). The only way to read the TikTok hook/script. Audience as ranges; CDN URLs may need extra permissions.
- **Optional page context:** `meta_ad_library_page_info` (`page_id` → transparency history, IG metrics, ad-spend range) for a competitor's overall Meta footprint.
### What each path reads
MCP-first delivers the full read below; a key only adds copy depth on Google + TikTok. State the split plainly when copy is dark:
| Capability | MCP (no key) | Needs API key |
|---|---|---|
| Snapshot + diff + longevity + channel-mix (all 4 networks) | ✅ | |
| **Meta** ad copy (title/body/CTA/link) | ✅ tier 2 | |
| **LinkedIn** ad copy (headline/CTA) | ✅ tier 2 | |
| **Google** ad copy (headline/description) | | ✅ tier 3 |
| **TikTok** hook/script text | | ✅ tier 3 |
| Meta/TikTok deep detail (reach, demographics, targeting) | | ✅ tier 3 |
When copy is dark, give the structural read plus a single upgrade line (see [Output](#output-the-report)); **never** substitute tier-2 metadata (a `details_link`, a video thumbnail) for copy, and never pad the report with repeated "unavailable" cells. The key setup lives in [`searchapi-onboarding`](../searchapi-onboarding/SKILL.md).
## Cost preflight
One tool call = one API request (MCP calls count against quota too). Projected calls for a run:
```
resolution: advertisers × networks-needing-tier-1 (Google + Meta + TikTok; LinkedIn = 0)
snapshot: advertisers × networks × pages (1 page each unless an advertiser has many ads)
enrichment: flagged ads × 1 (only if tier-3 copy depth is on, capped at K/advertiser)
```
A 4-competitor × 4-network weekly run is roughly **12 resolution + 16+ snapshot ≈ 30 calls**, more if advertisers paginate deep or tier-3 is on. Confirm with a single button ("Run ~N calls" / "Let me adjust") only when the projection exceeds **~40 calls**. Smaller runs just go, no cost lecture.
## Output: the report
Write to `.ads-monitor/<YYYY-MM-DD>-report.md`. **Lead with the read, not the counts.** Order:
**1. Summary line** (one sentence, the whole story, not an ad count):
```
SerpApi: paid budget is ~100 evergreen Google text ads (96 running 1y+), LinkedIn is litigation-PR not lead-gen, TikTok was a one-off, Meta wide open. Baseline run.
```
**2. Strategy read** (the headline). The synthesis from [Strategy read](#strategy-read-the-synthesis): a few sentences per competitor on budget concentration, evergreen vs. testing, what's new, positioning, and open channels. Every claim points to a number or a quoted ad.
**3. The ads you can read.** Show the **actual creative, not counts**: surface the real headlines, **description/body text**, and CTAs from the ads found, not just how many there are. Lead each competitor with their **best/representative headlines** (the long-runner winners first, since longevity proxies what converts), quoting the literal `headline`/`title`, the **`body`/description line**, and `cta` text from the JSON, each tagged with its longevity and a `NEW` flag if recently launched. Source the description text per network: **Meta** `snapshot.body.text`, **Google** tier-3 `ad_details` → `variations[].descriptions` (REST key only; tier-2 Google carries no text), **LinkedIn** headline + CTA only (no body line). Meta + LinkedIn copy is readable on any path; Google headlines **and descriptions** need a tier-3 key (offer the upgrade, see section 4) and TikTok hooks likewise. Where a network's description is path-gated, write the headline/CTA you have and append *"description needs a key"* on that line rather than dropping the ad. Group by angle/theme under the quoted lines. A report that lists "14 Google ads" without a single headline has failed this section. Example (each line is headline / description / CTA / longevity):
```markdown
**Acme Corp, representative headlines**
- "Cut Your Cloud Bill 40% in a Weekend" / desc: "Migrate idle workloads automatically and stop paying for capacity you never use. Free 14-day trial, no card." · CTA "Start Free Trial" · 412d running · long-runner [Meta, snapshot.body.text]
- "The Spreadsheet Your CFO Actually Trusts" / desc: "Real-time spend forecasting your finance team can audit line by line." · CTA "Book a Demo" · 28d · NEW [Meta]
- "Stop Paying for Seats You Don't Use" / desc: needs a key · CTA "See Pricing" · 190d running [Google, tier-2: headline+CTA dark until enriched]
```
When the run is MCP-only (no `SEARCHAPI_API_KEY`), the Meta/LinkedIn lines render in full and the Google/TikTok lines degrade to the headline/structural read with the *"description needs a key"* note; never substitute tier-2 metadata for the missing body and never drop the line.
**4. Google / TikTok structural read** (when copy is dark). Not empty "unavailable" rows: the longevity winners (their frozen, proven ads), the `recently_launched` set (what they just turned on), and the volume/format shape, each as a one-line read. Close with a single upgrade line: *"Want the actual headlines/scripts? Add a `SEARCHAPI_API_KEY` and re-run; the skill pulls copy for the top winners."*
**5. Likely target keywords (inferred from their creative)** (one subsection per competitor). The Transparency Center has **no free-text ad keyword search**, so this is **not a reported field**: it is **your** reverse-engineering of the search terms the competitor is likely bidding on, inferred only from the creative you actually pulled (the quoted headlines/descriptions of section 3), the resolved landing domain, and the resolved advertiser name. Give **3-6 inferred keyword themes**, and **anchor each one to the evidence line it is drawn from** (a quoted headline/description, the landing domain, or the advertiser name). Present it as a hypothesis, never as ground-truth ad targeting; label the block accordingly. Where copy is dark (Google/TikTok without a key), infer from the landing domain + advertiser name only and say the inference is thinner until copy is enriched. Worked example:
```markdown
**Acme Corp, likely target keywords (inferred, not reported by any library)**
- "cloud cost optimization" (evidence: "Cut Your Cloud Bill 40% in a Weekend" + "stop paying for capacity you never use")
- "reduce AWS bill" / "cut cloud spend" (evidence: "Cut Your Cloud Bill 40%" headline)
- "cloud spend forecasting" (evidence: "Real-time spend forecasting your finance team can audit")
- "finops platform" / "cfo cloud spend" (evidence: "The Spreadsheet Your CFO Actually Trusts" + finance angle)
- "cloud cost management software" (evidence: landing domain acme.com/finops + advertiser name Acme Corp)
_Inference from the creative above, not a targeting field any library exposes; treat as a hypothesis to validate, not as Acme's actual keyword list._
```
**6. What changed** (re-runs only). Launched / killed / relaunched / changed + channel signals, each traced to its `(network, ad_id)`. On a baseline run, one line: "baseline, no diff yet."
**7. Appendix** (reference, not headline): the per-competitor active-ads count table, the channel-mix matrix, and the full longevity table.
```markdown
| Competitor | Google | Meta | TikTok | LinkedIn | Total | Δ vs last run |
|---|---|---|---|---|---|---|
| Acme Corp | 14 | 31 | 8 | 5 | 58 | ▲6 |
```
Flag a genuinely path-limited field *"copy needs a key"* once, in context, never as a wall of repeated cells.
## Scheduling
This skill does **not** create schedules. Re-run it and the diff is automatic; the watchlist and history layer handle run-over-run comparison. For an unattended weekly competitor sweep, point the user at their existing cron / scheduled-task tooling to invoke the same request; nothing else is needed.
## Universal gotchas
- **Google Transparency Center has no free-text ad search.** Only the *advertiser_search* (tier 1) takes a keyword. Tier 2 needs an `advertiser_id` or `domain`. There is no category/keyword ad scan on Google, which is why landscape *discovery* is out of scope. The report's *likely target keywords (inferred)* read is a separate thing: your reverse-engineering from the creative, not a query against a keyword index, so never present it as the competitor's reported targeting.
- **TikTok `advertiser_id` alone no longer works.** Use `advertiser_token` (bundles id + name); re-mint it every run, never cache.
- **TikTok rejects `country=US`.** `tiktok_ads_library` covers EEA/UK/CH/TR only (supported: `all`, `AT`..`CH`, `TR`, `GB`); `US` errors. Send TikTok `country=all` (or an EEA/UK/CH/TR code) even when Google/Meta/LinkedIn use `US`.
- **TikTok silently defaults to a trailing 1-year window.** With no `time_period`, `tiktok_ads_library` returns only the last 12 months of ads, hiding older creatives with no error. Pass an explicit `time_period=YYYY-MM-DD..YYYY-MM-DD` for full history, or say in the report that the TikTok view is last-12-months only; don't read a dropped-off older ad as a kill.
- **Meta tokens overflow the URL.** `next_page_token` grows to 8KB+; switch to **POST** with the token in the JSON body on HTTP 413/414.
- **LinkedIn has no start date and no ad-details engine.** No `days_running`, no tier-3. Copy is headline + CTA only, and the response shape varies by `ad_type`, so parse defensively.
- **Google + TikTok copy is tier-3 only.** Tier-2 Google creatives carry no headline; TikTok tier-2 is video without script text. Both need the REST key.
- **Everything is ranges and buckets.** Impressions and audience are returned as ranges (`"100K-200K"`, `ads_count.{lower,upper}`). Compare buckets, never invent a point number.
- **Google results cap at 4,000 per search**; paginate with `next_page_token` up to that ceiling.
- **Reddit has no public ad library**, so there is no Reddit ad spying.
- **Spotted an ad in a live Google SERP, not the Transparency Center?** `google_ads_advertiser_info` (REST only) maps that ad's `advertiser_info_token` to an identity, but for tracking named competitors you resolve via the tier-1 advertiser search above; you don't need it here.
## Handoffs
- **Organic (non-ad) brand presence and mentions** → [`brand-monitoring`](../brand-monitoring/SKILL.md).
- **Engine-level params, deprecations, rate limits** → [`searchapi-best-practices`](../searchapi-best-practices/SKILL.md).
## Never fabricate
Every claim must trace to a runnable call plus a response excerpt:
1. The exact `curl` or MCP call run, with its identifiers (`advertiser_id` / `page_id` / `advertiser_token` / `advertiser`).
2. The response excerpt that backs the number or the quoted copy.
**A "launch" is real only if a prior snapshot exists on disk** and the `(network, ad_id)` was absent in it; on a baseline run, report the snapshot with no diff. Don't invent ad copy for a network the configured path couldn't fetch; mark it *"unavailable (REST API key required)"*. Don't impute an impression or audience number from a range bucket. Don't claim a competitor has no long-runners when the real reason is the network (LinkedIn) publishes no start date; say which it is.
No comments yet. Be the first to comment!