Spy competitor ads skill

Spy competitor ads — Poster frame: a crumpled plastic bottle with sad eyes lying in a wet gutter under a street lamp (from the novoads/agent-skills README)

Illustration · not a resultFrom the novoads/agent-skills README — shows the whole collection, not only this skill.source ↗

Downloads a competitor's live ad creatives — the actual MP4s and JPEGs — from the Meta Ad Library into a local swipe folder, through the Novoads API.

by novoads·MIT license·★ 23 Stars on the repo·GitHub ↗

Use now
Spy competitor ads — Poster frame: a crumpled plastic bottle with sad eyes lying in a wet gutter under a street lamp (from the novoads/agent-skills README)

Illustration · not a resultFrom the novoads/agent-skills README — shows the whole collection, not only this skill.view on GitHub

Files of Spy competitor ads

novoads/main1 file shown
SKILL.md
Show the full text497 lines

Spy Competitor Ads

Find what a competitor is actually running, and put the files on disk. That is the whole job.

It ranks what it finds, on evidence the Ad Library actually publishes — how long a creative has run and how many audiences it runs against. It does not judge craft, explain "why it's winning", or write a brief; it has not watched these ads and neither have you. What you do with them belongs to clone-image-ad, which turns a static into your own ads, a reusable template, or both.

The mechanics are one HTTP call per competitor. Everything that makes a run good or bad is judgement: which competitors, which media mode, and which of the returned ads are actually the brand's rather than somebody else's.

Before anything: this runs on a Novoads account

  • Base URL: https://api.novoads.ai/v1 (host overridable with NOVOADS_BASE_URL — host only, you append /v1).
  • Auth: Authorization: Bearer $NOVOADS_API_KEY, read from .env at the repo root. The key is novo_ plus 64 hex.
  • Check: ./scripts/check-novoads-env.sh
  • Never print API keys, commit .env, or paste a key into MASTER_CONTEXT.md.

REST key required. A Novoads MCP connector is not a substitute. If NOVOADS_API_KEY is missing or still the placeholder, stop before any generation work and tell the user: "Before continuing, create an API key at https://novoads.ai/dashboard/settings?tab=api and paste it into .env." That holds even when mcp__novoads__* tools are connected and authenticated in the session. Never call mcp__novoads__* tools from this repo's workflows: they are a different surface with different behavior, including the units they quote costs in. Repo installs verify with ./scripts/check-novoads-env.sh; a solo install checks NOVOADS_API_KEY in the environment.

Pack version. Every /v1 response carries X-Novoads-Pack-Version; mention a newer pack at https://github.com/novoads/agent-skills only when that header names a version NEWER than this file's metadata.packVersion — equal or older is nothing to say, and it is never a reason to stop.

A 401 means the key is wrong, revoked, or from another account. A 403 with error.details.reason of plan_required or subscription_inactive means the key is fine and the account has no live subscription. Different problems, different fixes — say which one it is.

No account yet? https://novoads.ai/?utm_source=claude-code&utm_medium=github&utm_campaign=skill-pack There is no free tier and no trial: every plan is a paid monthly subscription, published at novoads.ai/pricing. Never call it free.

If this deployment does not offer sweeps yet. The endpoint is behind a flag that is off by default. With it off, POST /v1/competitor-ads and a kind: "competitor-ads" estimate both answer 400 invalid_input, and the message names what the API does offer — it is not a 404, and the path existing is not evidence it is enabled. Read that as "this deployment does not sell sweeps yet", say so plainly, and stop. Do not retry it, do not work around it with a browser, and do not treat the 400 as a body you got wrong.

POST /v1/competitor-ads is the only sweep this skill runs. Verified live 2026-08-08 against spec 2.19.0: HTTP 200, ads returned, creditsCharged: 0.2, matching a {"kind":"competitor-ads"} estimate taken in the same session.

Golden rules

  1. Price it live, every time. Every credit number you say out loud came from a POST /v1/estimates call in this session. There are no rate tables in this file, in this repo, or in the logs, and a remembered fee is a lie waiting to happen.
  2. The fee is flat per sweep. Never quote a per-ad price. Asking for 5 ads costs exactly what asking for 20 costs, because the vendor bills a ten-result minimum underneath. N competitors is N charges, and that multiplication is the only arithmetic you do out loud.
  3. Download before you write a word. The response carries urlsExpire: true and Meta's own token-bound CDN links. A response filed away for later is a list of dead links. curl every media URL the moment the sweep returns; adLibraryUrl is the durable one and it is what you quote.
  4. Zero ads is a report, not an apology. An empty sweep is a success, it is charged, and it is said in one line. See Zero below — this is the rule this skill exists to hold.
  5. The results are not filtered for you, and filtering them is your job. A keyword search returns third-party pages running the brand's creatives, and sometimes an unrelated company trading under the same name. Both arrive as 200 OK. See Reading the result.
  6. Ask in prose, one question at a time. No option menus. Carry a default so silence is never a blocker, name an escape hatch, and never ask twice about the same thing.

Step 1 — resolve the sweep

Four things decide a run. Take each in order, take it in one line, and only ask about the ones you genuinely cannot resolve.

Brand. Named in the request, or obvious from the repo's MASTER_CONTEXT.md or the product you have been working on → use it, and say which one you took. Otherwise ask once: "Which brand am I spying for?" Do not guess the brand — every downstream judgement about what counts as pollution depends on it.

Media mode. The API requires it and sweep.py requires it, so SOMETHING has to choose — look for an explicit trigger first:

They said mediaType
"video ads", "their videos", "hooks", "UGC they're running" "video"
"static ads", "image ads", "posters", "creatives to print" "image"
"both", "everything", "the full swipe file" "all"

Silent → take "image", say you took it, and name the escape in the same line. Do not ask; a question the run can answer for itself is work handed back to the user.

Sweeping their static ads — 20 slots each, all statics. Say "video" or "both" instead.

Why statics rather than "all", when "all" costs the same: the FEE is flat, the SLOTS are not. count maxes at 20 per sweep whatever mode you ask for, so "all" spends those 20 on a mix the user did not choose. Measured on a real three-competitor run, 2026-08-10: "all" returned 20 video and 0 static for one brand and 13/6 for another. Someone who wanted a static from the first brand got nothing, at the same price that would have bought twenty. And the clone that follows is the expensive half — a static render costs a fraction of a video one, so the cheaper next step is the better default.

Say the slot cost out loud when they ask for "all", because it is the part the flat fee hides: one kind gets you 20 of that kind; "both" splits the 20. That is the whole trade, and it is not a reason to refuse them — just a reason they should hear it once.

Competitors. Named → use them exactly as given, including the ones you would not have picked. Not named → find 3 to 5 with WebSearch (brands selling something similar to a similar audience that plausibly run paid social), then put the shortlist in front of the user before opening a single sweep, in prose:

I'd sweep Arcads, Creatify and Icon — all three sell AI UGC ads to performance marketers. Three sweeps, three charges. Swap or add any before I start.

That confirmation is not politeness. Each sweep spends, and a list the user has never seen is a list you invented.

Count and country. count defaults to 20, and 20 is right almost always: the fee is flat, so a smaller number buys less for the same money. Honor an explicit smaller number, and say once that it does not save anything. country defaults to "ALL"; narrow it to an ISO 3166-1 alpha-2 code only when the question is genuinely about one market, and say that narrowing can empty a sweep you still pay for.

Step 2 — price it before you sweep

curl -sS -X POST https://api.novoads.ai/v1/estimates \
  -H "Authorization: Bearer $NOVOADS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"kind":"competitor-ads"}'

Returns credits, balance and sufficient; when the balance is short it also returns shortBy and topUpUrl. It spends nothing and it runs the same access checks the paid call runs, which is why it is worth calling every time rather than only when you are unsure.

The estimate body is strict and only takes fields that move the price. The fee is flat per sweep, so kind is the whole body. If the arm asks for more, the 400 names what is missing — send it and re-quote. Never fall back to a number you remember.

Then announce, in one line, and get a yes before the first sweep:

3 competitors × 1 sweep each = 3 charges at N credits, N total (balance: B). Starting?

Two cases change it. sufficient: false is a blocker, not a warning: stop, say exactly what is short, and give topUpUrl. And if the balance covers this run but not a second one, say so in one clause before firing — a user who knows that picks their competitors more carefully.

Step 3 — sweep them all at once, and download as they land

./scripts/sweep.py --queries "arcads.ai,creatify.ai,icon.com" --media video --yes

One call, all competitors, in parallel up to the API's ceiling of ten concurrent sweeps — a budget kept separate from renders precisely so sweeps cannot starve a caller's next clip. Each competitor's creatives are downloaded inside its own unit of work, the moment that response lands, so the CDN clock starts and stops per competitor instead of after the slowest one. It writes outputs/competitor-ads/<slug>/{sweep.json,swept.json} plus the media, retries a 429 once using the server's own Retry-After, and reconciles what landed against what was promised by counting FILES ON DISK.

--yes is required and the script refuses without it, naming the estimate. It spends one charge per competitor and it is called by an agent; a script that can spend when nobody said yes is the one failure worth being rude about. Price it and get the yes first — that half is judgement and stays with you.

Read its summary out. On a shortfall it says which competitor was short and where the failed ids are; do not present a short set as the full one.

This used to say "one competitor per call, sequentially." That was wrong twice over: the justification was a single probe's "about ten seconds" that a real three-competitor run contradicted in minutes, and the habit itself came from the RETIRED browser version, which serialised to avoid bot detection while driving Chrome at Meta. Apify does the scraping now. Nine of the ten slots were idle.

query is a keyword search, not a page lookup. A domain (arcads.ai) or an @handle is a sharper query than a bare word, for the same reason it was in the browser era: bare words collide with unrelated companies. It takes 2 to 200 characters.

What comes back
POST /v1/competitor-ads
  request   query       brand name or domain, 2..200 chars          required
            mediaType   "video" | "image" | "all"                   required, no default
            country     ISO 3166-1 alpha-2, or "ALL"                default "ALL"
            count       1..20                                       default 20

  response  ads[]       adArchiveId     the Ad Library's own id for this ad
                        pageName        the page RUNNING it — not necessarily the brand
                        pageId
                        adLibraryUrl    permanent, public, the durable record
                        bodyText        the ad's primary copy, or null
                        collationCount  how many audiences this creative runs against, or null
                        startDate       when it went live, or null
                        endDate         or null
                        isActive
                        platforms[]     FACEBOOK, INSTAGRAM, THREADS, …
                        media           { kind: "video" | "image",
                                          videoHdUrl?, videoSdUrl?,
                                          previewImageUrl?, imageUrl? }
            query, mediaType, country   echoed back, so a saved sweep.json is self-describing
            urlsExpire  always true     the media URLs are already dying. See step 4
            creditsCharged              what this sweep cost. Log this, never recompute it

Ads arrive ranked: collationCount descending, then most recent first, deduplicated, truncated to count. That ordering is mechanical. There is no server-side "which ad is winning" — that judgement is yours and it is step 5.

Two things the list will not contain, so you do not go looking for them: the same creative twice (it is collapsed on Meta's ad id, on Meta's collation id, and — for ads Meta groups under nothing — on the opening of the ad's own copy), and ads with no fetchable file. Carousels and dynamic creative are often published without a video or image on the ad itself, and an entry you cannot download is not a creative to study. So ads can be shorter than what the Ad Library shows in a browser, and a brand whose whole live set is that shape comes back as ads: [].

Step 4 — check what actually landed

sweep.py already downloaded everything and printed landed/expected per competitor, counted from FILES ON DISK rather than from its own successes. Read that line before you write a word of delivery.

A shortfall is said out loud, in the first sentence, or not at all. A partial set delivered as complete looks exactly like a competitor who runs four ads — that is the failure this whole step exists to prevent, and it was measured: a browser-era run reporting five successes produced exactly one file.

On a shortfall you have two moves, and delivering quietly is neither: re-fetch the named ids from the sweep.json you already have, or deliver with the count named and the adLibraryUrls for what is missing. references/recovery.md has the loop. Never re-sweep to refresh a link — that is a second charge for ads already paid for.

Never log a media URL. It is a credential while it lives.

Step 5 — read the result with judgement

pageName is who is running the ad, not who makes the product

Nothing is filtered server-side, and that is deliberate. On the probe behind this endpoint (query arcads.ai, video, 2026-08-08) ten live ads came back across three pages, and only three of the ten sat on the brand's own page. The rest were affiliate and reseller pages running the brand's creatives, one of them carrying more live ads than the brand itself.

That is signal, not noise. An affiliate outspending the brand on the brand's own creatives is one of the more useful things a sweep can tell you, and a server-side brand filter would have silently deleted it.

So read pageName and bodyText on every ad and sort them into three groups:

  • The brand's own page — what they are running themselves.
  • Third parties running the brand's creatives — affiliates, resellers, agencies. Keep these. Say who they are.
  • Same-name strangers — a genuinely unrelated company trading under the same word. The canonical case is "Creatify" the AI tool versus "Creatify.mx" a sticker shop. Drop these.

Name the groups in the delivery and say what you dropped and why. A filter the user cannot see is a filter they cannot correct, and the correction is usually one sharper query away.

Rank it with the script, not by eye
./scripts/rank-ads.py "$DIR/sweep.json"                  # the words
./scripts/make-picker.py "$DIR/sweep.json"               # the page

The first prints the delivery's opening: what the sample is, the twelve picks with their basis, who is running them, the metadata-not-craft line. Read it out; do not recompute it.

Then get it in front of them, and pick the way by what the session can actually do. A page on disk is invisible in a remote sandbox, over SSH, and to anyone looking at a chat window rather than a desktop. Never say "it is open in your browser" — you cannot verify that, and when it is wrong the user is staring at a screen where nothing happened.

If your harness can render an HTML file inline in the conversation — Claude Code publishes one as an artifact — that is the best answer. It works locally and remotely, and the cards stay clickable. Build it with --embed, then publish that file:

./scripts/make-picker.py "$DIR/sweep.json" --embed --out "$DIR/artifact.html"

Publish it with a title naming the brand and the count, and tell the user plainly that the creatives are uploaded to render: they are a competitor's copyrighted work, the page is private to them, and the on-disk copy stays local either way. Say it once, not every run.

--embed is not optional there: an artifact's CSP blocks every external host, so a page that references the files beside it renders as twelve broken frames. Videos embed as their poster frame and say so — twelve mp4s as data URIs is roughly 47 MB against a 16 MB ceiling — and the script drops the tail rather than exceeding it, saying which on the page.

Otherwise fall back to the file on disk, name its absolute path, and offer to open it. Either way, also put the top few creatives in the conversation by reading the downloaded images. That is the one thing that works in every environment, and it is what makes the page a richer view rather than the only one:

Top 3 of 12 below. Full sheet: /abs/path/picker.html — say the word and I'll open it.

The second writes a contact sheet beside the sweep: twelve creatives at real size, videos playable in place, each card numbered and carrying its page, dates and Ad Library link. Comparing twelve ads as filenames is not comparing them. Open it and say:

12 candidates, and picker.html is open. Click the ones you want, or say "clone 3" — or "clone 1, 4, 7" for several.

Cards toggle, nothing starts selected, and the bar copies clone 1, 4, 7. Each number is a separate clone run, so selecting six is six priced runs, not one — the bar says so and the estimate still gates every one of them. The ordinal is the contract and the click is the convenience, so a blocked clipboard costs nothing. Both scripts share one sort and one default — never renumber by hand. A page rather than an option prompt on purpose: a prompt blocks, and this flow proposes and proceeds.

The sort is collationCount descending then longest-running, which is arithmetic, and the script also decides the thing prose kept getting subtly wrong: whether the audience-count signal is alive at all. Measured on a real sweep of this category, every row came back 1 or null, so the field the ordering nominally leads on contributed nothing and run length decided everything — while the delivery still recited both bases. The script detects that and says "run length only" instead. --top N changes how many BOTH produce (12 by default — a grid of twelve is one glance where a list of twelve is a scroll); --json gives you signalAlive and the counts if you are formatting your own.

Three claims it carries that are easy to get wrong by hand:

  • The SAMPLE is the strongest thing you can say. The Ad Library is queried with sort_data[mode]=total_impressions descending — the same "Impressions: high to low" a human gets in the UI — so every ad in a sweep is among Meta's highest-impression ads for that query. Lead with it.
  • There is no impressions NUMBER. Meta publishes per-ad impressions only for political and issue ads, and in the EU. Never quote one, never infer one from position.
  • The ORDER is ours, not Meta's. The API re-sorts by collationCount then recency before you see it, so position 1 is not Meta's #1. The impressions claim belongs to the sample, not to the order within it.

What stays yours is the only part no sort can decide: which of the three is worth cloning, read against the product you are cloning for. And it never becomes a craft judgement — you have not watched these ads. Reading one properly is analyze_ad, charged per ad, which against a twenty-ad sweep is an order of magnitude more than the sweep cost. Offer it on the top one to three as its own priced step; never quietly upgrade "rank these" into twenty reads.

collationCount is recurrence, not spend

It is the closest thing to a spend signal the Ad Library exposes: one creative shown against twelve audiences appears as collationCount: 12. High collation plus an old startDate is the strongest available evidence that a creative is working, because nobody keeps paying to run a loser for months.

Two honesties about it. null is common and means not reported, not zero. And it is not a budget: never turn a collation count into a dollar figure or a "they're spending X" claim. The Ad Library does not publish spend for commercial ads, and inventing the number is worse than leaving it out.

Zero

An empty sweep is a result. It is charged, and it is correct. The vendor underneath bills its minimum whether or not the query matched anything, so ads: [] arriving as a 200 means the Ad Library genuinely has nothing live for that brand, in that media mode, in that country.

Say it in one line and move on:

No live video ads for BrandX in ALL right now.

Then stop. Specifically:

  • Do not apologise for it. It is often the most valuable line in the whole run — a competitor who has gone dark is a finding.
  • Do not re-run the same sweep hoping for a different answer. It is the same query against the same index, and it is a second charge.
  • Do not quietly widen the country or switch the media mode to manufacture a result. If a different question is worth asking, ask it out loud, say it is another charge, and get a yes.
  • Do not pad it with older creatives, a different brand, or the other media mode.

This rule is the reason this skill survived its rewrite. The browser-driven version could not tell an honest zero from a broken DOM heuristic, and reporting the second as "this brand runs no ads" was its worst failure mode. That ambiguity is now gone: a scrape that fails is a provider_failed (HTTP 502) with the credits refunded, and it never reaches you as an empty list.

One honest zero has a second cause worth naming when you see it: some ad formats — carousels and dynamic creative — are published with no downloadable file on the ad itself, and those are left out of ads. So a brand whose entire live set is that shape comes back as ads: [] on a charged call. It is still a finding ("nothing fetchable is running right now"), and adLibraryUrl on the Ad Library search page is where a human can still look.

Step 6 — deliver, then offer the clone once

Deliver per competitor: the files on disk with their paths, the pageName groups, and one line of what is live versus dark.

Say the count first, and say it from the reconciliation in step 4 — "9 of 10 creatives downloaded", not "here is their swipe file". If files are missing, that goes in the same sentence with the adLibraryUrls for the ones that did not land. A set delivered without its count reads as complete, and the user studies four ads believing they are the whole answer they paid for.

Then offer the next step once, in one line, naming a skill that exists in this checkout:

  • A static you want rebuilt as your own ads, or turned into a reusable template, or both → clone-image-ad.
  • A video you want rebuilt for a different product, delivered as a finished MP4 → clone-video-ad. Hand over the downloaded file path; it reads the source itself.

Offer, do not start. Either one spends, and neither is what was asked for here. There is no hook-only skill in this pack, so an opening on its own routes to clone-video-ad too — clone the whole ad and cut.

Skip the offer entirely when this skill was invoked as a sub-step of something else. If another skill or workflow asked for a swipe file, hand back the file paths and the ranked top three and stop — the caller owns what happens next, and an offer injected into the middle of its flow hijacks it.

Step 7 — file the corpus so it is not re-bought

The sweep is the charge; the files are the asset. Record beside the downloads, in $DIR/sweep.json's own directory, what was swept and when:

jq -n --arg q "$QUERY" --arg m "$MEDIA" --arg c "$COUNTRY" --arg at "$(date -u +%FT%TZ)" \
  '{query:$q, mediaType:$m, country:$c, sweptAt:$at}' > "$DIR/swept.json"

sweep.json already echoes the query, media mode and country back, which is why a saved sweep is self-describing. The scan date is the part it does not carry, and it is the part that decides everything below.

Before sweeping anything, look for an existing corpus for that brand and media mode.

  • Under 7 days old — reuse it. Do not sweep, do not charge. Say which stored sweep you are using and when it was taken.
  • 7 days or older — say the date and the age unprompted, offer a refresh with its price, and wait. Never refresh silently, and never present stored ads as what the brand is running now. The freshness number is not arbitrary: the other competitor corpus in this repo sat at eight days with no threshold at any age and presented itself as healthy while three filters hid a post the founder found by hand.
  • Absent — sweep, after the estimate and the announced total. Reuse is an optimisation on top of the price gate, never a way around it.

A stored corpus is re-minable: "clone the second one too" costs nothing. The media URLs are dead within the hour, but the downloaded files are not, and adLibraryUrl never expires.

Errors

references/errors.md — every error.code this surface emits, what each means, and what to do. Branch on the code, never on the message. Two that matter before you read anything: provider_failed (502) is NOT an empty result — the scrape failed and the credits were refunded — and a call that times out on your side was probably charged, so reconcile it before retrying rather than paying twice.

Background

references/background.md — installing this skill outside the pack, and where the retired browser-driven version went. Neither is needed to run a sweep.

references/legal.md — the Meta Ad Library is public, this skill does not scrape it (the Novoads API does, through a commercial vendor, the same shape as Foreplay or AdSpy), and the creatives you download are somebody else's copyrighted work. Studying a swipe file and rebuilding an idea is not re-running a competitor's ad.

Hard rules

  • Every credit number comes from a live estimate in this session. No rate tables, ever.
  • Never quote a per-ad price. The fee is flat per sweep.
  • Download the media before writing the delivery. adLibraryUrl is what you quote.
  • One sweep per competitor, fanned out to the API's ceiling by sweep.py, against a list the user has seen and priced.
  • Never ask twice about the same thing, and never with a menu. A question the run can answer for itself is not a question — media mode defaults to image and says so.
  • An empty sweep is reported in one line and never retried.
  • Never turn collationCount into a spend figure.
  • Never invent an ad, pad a thin result, or deliver a group you filtered without saying so.
  • Close with a ranked top three and the basis for it. Never rank on craft you have not paid to read.
  • Check for a stored corpus before sweeping. Under 7 days, reuse it and charge nothing; older, say its age and price the refresh; absent, sweep after the estimate. Reuse never skips the price gate.
1---
2name: spy-competitor-ads
3metadata: {packVersion: 1.3.0}
4description: >-
5 Downloads a competitor's live ad creatives — the actual MP4s and JPEGs — from the Meta Ad
6 Library into a local swipe folder, through the Novoads API. One sweep per competitor,
7 priced by a live estimate before anything is charged, media pulled the moment the response
8 lands because the CDN links expire, and every ad carried with its permanent Ad Library
9 URL, the page running it, and how many audiences the creative is running against, closing
10 with the three most established and why. Use when asked to spy on competitors, find what
11 ads a brand is running, pull a competitor's video or static creatives, build or refresh a
12 swipe file, check who is outspending you on Meta, or source a reference ad to recreate.
13 It finds, ranks and files ads; it does not rebuild them — a static becomes your own ads,
14 or a reusable template, via clone-image-ad. It writes no craft analysis and no creative
15 brief. A sweep that finds nothing is a result, never an apology.
16---
17 
18# Spy Competitor Ads
19 
20Find what a competitor is actually running, and put the files on disk. That is the whole job.
21 
22It ranks what it finds, on evidence the Ad Library actually publishes — how long a creative has
23run and how many audiences it runs against. It does **not** judge craft, explain "why it's
24winning", or write a brief; it has not watched these ads and neither have you. What you do with
25them belongs to `clone-image-ad`, which turns a static into your own ads, a reusable template,
26or both.
27 
28The mechanics are one HTTP call per competitor. Everything that makes a run good or bad is
29judgement: which competitors, which media mode, and which of the returned ads are actually the
30brand's rather than somebody else's.
31 
32## Before anything: this runs on a Novoads account
33 
34- **Base URL:** `https://api.novoads.ai/v1` (host overridable with `NOVOADS_BASE_URL` — host
35 only, you append `/v1`).
36- **Auth:** `Authorization: Bearer $NOVOADS_API_KEY`, read from `.env` at the repo root. The key
37 is `novo_` plus 64 hex.
38- **Check:** `./scripts/check-novoads-env.sh`
39- **Never** print API keys, commit `.env`, or paste a key into `MASTER_CONTEXT.md`.
40 
41> **REST key required. A Novoads MCP connector is not a substitute.** If
42> `NOVOADS_API_KEY` is missing or still the placeholder, stop before any
43> generation work and tell the user: "Before continuing, create an API key at
44> <https://novoads.ai/dashboard/settings?tab=api> and paste it into `.env`."
45> That holds even when `mcp__novoads__*` tools are connected and authenticated in
46> the session. Never call `mcp__novoads__*` tools from this repo's workflows: they
47> are a different surface with different behavior, including the units they quote
48> costs in. Repo installs verify with `./scripts/check-novoads-env.sh`; a solo
49> install checks `NOVOADS_API_KEY` in the environment.
50 
51**Pack version.** Every `/v1` response carries `X-Novoads-Pack-Version`; mention a newer pack at <https://github.com/novoads/agent-skills> only when that header names a version NEWER than this file's `metadata.packVersion` — equal or older is nothing to say, and it is never a reason to stop.
52 
53A `401` means the key is wrong, revoked, or from another account. A `403` with
54`error.details.reason` of `plan_required` or `subscription_inactive` means the key is fine and
55the account has no live subscription. Different problems, different fixes — say which one it is.
56 
57No account yet? **<https://novoads.ai/?utm_source=claude-code&utm_medium=github&utm_campaign=skill-pack>**
58There is no free tier and no trial: every plan is a paid monthly subscription,
59published at novoads.ai/pricing. Never call it free.
60 
61**If this deployment does not offer sweeps yet.** The endpoint is behind a flag that is off by
62default. With it off, `POST /v1/competitor-ads` and a `kind: "competitor-ads"` estimate both
63answer **`400 invalid_input`**, and the message names what the API *does* offer — it is not a
64`404`, and the path existing is not evidence it is enabled. Read that as "this deployment does not
65sell sweeps yet", say so plainly, and stop. Do not retry it, do not work around it with a
66browser, and do not treat the 400 as a body you got wrong.
67 
68`POST /v1/competitor-ads` is the only sweep this skill runs. Verified live 2026-08-08 against
69spec `2.19.0`: `HTTP 200`, ads returned, `creditsCharged: 0.2`, matching a
70`{"kind":"competitor-ads"}` estimate taken in the same session.
71 
72## Golden rules
73 
741. **Price it live, every time.** Every credit number you say out loud came from a
75 `POST /v1/estimates` call in this session. There are no rate tables in this file, in this
76 repo, or in the logs, and a remembered fee is a lie waiting to happen.
772. **The fee is flat per sweep.** Never quote a per-ad price. Asking for 5 ads costs exactly
78 what asking for 20 costs, because the vendor bills a ten-result minimum underneath. N
79 competitors is N charges, and that multiplication is the only arithmetic you do out loud.
803. **Download before you write a word.** The response carries `urlsExpire: true` and Meta's own
81 token-bound CDN links. A response filed away for later is a list of dead links. `curl` every
82 media URL the moment the sweep returns; `adLibraryUrl` is the durable one and it is what you
83 quote.
844. **Zero ads is a report, not an apology.** An empty sweep is a success, it is charged, and it
85 is said in one line. See *Zero* below — this is the rule this skill exists to hold.
865. **The results are not filtered for you, and filtering them is your job.** A keyword search
87 returns third-party pages running the brand's creatives, and sometimes an unrelated company
88 trading under the same name. Both arrive as `200 OK`. See *Reading the result*.
896. **Ask in prose, one question at a time.** No option menus. Carry a default so silence is
90 never a blocker, name an escape hatch, and never ask twice about the same thing.
91 
92## Step 1 — resolve the sweep
93 
94Four things decide a run. Take each in order, take it in one line, and only ask about the ones
95you genuinely cannot resolve.
96 
97**Brand.** Named in the request, or obvious from the repo's `MASTER_CONTEXT.md` or the product
98you have been working on → use it, and say which one you took. Otherwise ask once: *"Which
99brand am I spying for?"* Do not guess the brand — every downstream judgement about what counts
100as pollution depends on it.
101 
102**Media mode.** The API requires it and `sweep.py` requires it, so SOMETHING has to choose —
103look for an explicit trigger first:
104 
105| They said | `mediaType` |
106|---|---|
107| "video ads", "their videos", "hooks", "UGC they're running" | `"video"` |
108| "static ads", "image ads", "posters", "creatives to print" | `"image"` |
109| "both", "everything", "the full swipe file" | `"all"` |
110 
111Silent → **take `"image"`, say you took it, and name the escape in the same line.** Do not ask;
112a question the run can answer for itself is work handed back to the user.
113 
114> Sweeping their **static** ads — 20 slots each, all statics. Say "video" or "both" instead.
115 
116**Why statics rather than `"all"`, when `"all"` costs the same:** the FEE is flat, the SLOTS are
117not. `count` maxes at 20 per sweep whatever mode you ask for, so `"all"` spends those 20 on a
118mix the user did not choose. Measured on a real three-competitor run, 2026-08-10: `"all"`
119returned **20 video and 0 static** for one brand and 13/6 for another. Someone who wanted a
120static from the first brand got nothing, at the same price that would have bought twenty. And
121the clone that follows is the expensive half — a static render costs a fraction of a video one,
122so the cheaper next step is the better default.
123 
124Say the slot cost out loud when they ask for `"all"`, because it is the part the flat fee hides:
125**one kind gets you 20 of that kind; "both" splits the 20.** That is the whole trade, and it is
126not a reason to refuse them — just a reason they should hear it once.
127 
128**Competitors.** Named → use them exactly as given, including the ones you would not have
129picked. Not named → find 3 to 5 with `WebSearch` (brands selling something similar to a similar
130audience that plausibly run paid social), then put the shortlist in front of the user before
131opening a single sweep, in prose:
132 
133> I'd sweep Arcads, Creatify and Icon — all three sell AI UGC ads to performance marketers.
134> Three sweeps, three charges. Swap or add any before I start.
135 
136That confirmation is not politeness. Each sweep spends, and a list the user has never seen is a
137list you invented.
138 
139**Count and country.** `count` defaults to **20**, and 20 is right almost always: the fee is
140flat, so a smaller number buys less for the same money. Honor an explicit smaller number, and
141say once that it does not save anything. `country` defaults to `"ALL"`; narrow it to an ISO
1423166-1 alpha-2 code only when the question is genuinely about one market, and say that narrowing
143can empty a sweep you still pay for.
144 
145## Step 2 — price it before you sweep
146 
147```bash
148curl -sS -X POST https://api.novoads.ai/v1/estimates \
149 -H "Authorization: Bearer $NOVOADS_API_KEY" \
150 -H "Content-Type: application/json" \
151 -d '{"kind":"competitor-ads"}'
152```
153 
154Returns `credits`, `balance` and `sufficient`; when the balance is short it also returns
155`shortBy` and `topUpUrl`. It spends nothing and it runs the same access checks the paid call
156runs, which is why it is worth calling every time rather than only when you are unsure.
157 
158The estimate body is strict and only takes fields that move the price. The fee is flat per
159sweep, so `kind` is the whole body. If the arm asks for more, the `400` names what is missing —
160send it and re-quote. **Never fall back to a number you remember.**
161 
162Then announce, in one line, and get a yes before the first sweep:
163 
164> 3 competitors × 1 sweep each = 3 charges at N credits, N total (balance: B). Starting?
165 
166Two cases change it. `sufficient: false` is a blocker, not a warning: stop, say exactly what is
167short, and give `topUpUrl`. And if the balance covers this run but not a second one, say so in
168one clause before firing — a user who knows that picks their competitors more carefully.
169 
170## Step 3 — sweep them all at once, and download as they land
171 
172```bash
173./scripts/sweep.py --queries "arcads.ai,creatify.ai,icon.com" --media video --yes
174```
175 
176One call, all competitors, **in parallel up to the API's ceiling of ten concurrent sweeps** —
177a budget kept separate from renders precisely so sweeps cannot starve a caller's next clip.
178Each competitor's creatives are downloaded inside its own unit of work, the moment that
179response lands, so the CDN clock starts and stops per competitor instead of after the slowest
180one. It writes `outputs/competitor-ads/<slug>/{sweep.json,swept.json}` plus the media, retries
181a 429 once using **the server's own `Retry-After`**, and reconciles what landed against what
182was promised by counting FILES ON DISK.
183 
184**`--yes` is required and the script refuses without it**, naming the estimate. It spends one
185charge per competitor and it is called by an agent; a script that can spend when nobody said
186yes is the one failure worth being rude about. Price it and get the yes first — that half is
187judgement and stays with you.
188 
189Read its summary out. On a shortfall it says which competitor was short and where the failed
190ids are; do not present a short set as the full one.
191 
192**This used to say "one competitor per call, sequentially."** That was wrong twice over: the
193justification was a single probe's "about ten seconds" that a real three-competitor run
194contradicted in minutes, and the habit itself came from the RETIRED browser version, which
195serialised to avoid bot detection while driving Chrome at Meta. Apify does the scraping now.
196Nine of the ten slots were idle.
197 
198**`query` is a keyword search, not a page lookup.** A domain (`arcads.ai`) or an `@handle` is a
199sharper query than a bare word, for the same reason it was in the browser era: bare words collide
200with unrelated companies. It takes 2 to 200 characters.
201 
202### What comes back
203 
204```
205POST /v1/competitor-ads
206 request query brand name or domain, 2..200 chars required
207 mediaType "video" | "image" | "all" required, no default
208 country ISO 3166-1 alpha-2, or "ALL" default "ALL"
209 count 1..20 default 20
210 
211 response ads[] adArchiveId the Ad Library's own id for this ad
212 pageName the page RUNNING it — not necessarily the brand
213 pageId
214 adLibraryUrl permanent, public, the durable record
215 bodyText the ad's primary copy, or null
216 collationCount how many audiences this creative runs against, or null
217 startDate when it went live, or null
218 endDate or null
219 isActive
220 platforms[] FACEBOOK, INSTAGRAM, THREADS, …
221 media { kind: "video" | "image",
222 videoHdUrl?, videoSdUrl?,
223 previewImageUrl?, imageUrl? }
224 query, mediaType, country echoed back, so a saved sweep.json is self-describing
225 urlsExpire always true the media URLs are already dying. See step 4
226 creditsCharged what this sweep cost. Log this, never recompute it
227```
228 
229Ads arrive **ranked**: `collationCount` descending, then most recent first, deduplicated,
230truncated to `count`. That ordering is mechanical. There is no server-side "which ad is
231winning" — that judgement is yours and it is step 5.
232 
233Two things the list will not contain, so you do not go looking for them: the same creative twice
234(it is collapsed on Meta's ad id, on Meta's collation id, and — for ads Meta groups under nothing —
235on the opening of the ad's own copy), and ads with no fetchable file. Carousels and dynamic
236creative are often published without a video or image on the ad itself, and an entry you cannot
237download is not a creative to study. So `ads` can be shorter than what the Ad Library shows in a
238browser, and a brand whose whole live set is that shape comes back as `ads: []`.
239 
240## Step 4 — check what actually landed
241 
242`sweep.py` already downloaded everything and printed `landed/expected` per competitor, counted
243from FILES ON DISK rather than from its own successes. Read that line before you write a word
244of delivery.
245 
246**A shortfall is said out loud, in the first sentence, or not at all.** A partial set delivered
247as complete looks exactly like a competitor who runs four ads — that is the failure this whole
248step exists to prevent, and it was measured: a browser-era run reporting five successes produced
249exactly one file.
250 
251On a shortfall you have two moves, and delivering quietly is neither: re-fetch the named ids
252from the sweep.json you already have, or deliver with the count named and the `adLibraryUrl`s
253for what is missing. `references/recovery.md` has the loop. **Never re-sweep to refresh a
254link** — that is a second charge for ads already paid for.
255 
256**Never log a media URL.** It is a credential while it lives.
257 
258## Step 5 — read the result with judgement
259 
260### `pageName` is who is running the ad, not who makes the product
261 
262Nothing is filtered server-side, and that is deliberate. On the probe behind this endpoint
263(query `arcads.ai`, video, 2026-08-08) ten live ads came back across **three** pages, and only
264three of the ten sat on the brand's own page. The rest were affiliate and reseller pages running
265the brand's creatives, one of them carrying more live ads than the brand itself.
266 
267That is signal, not noise. An affiliate outspending the brand on the brand's own creatives is
268one of the more useful things a sweep can tell you, and a server-side brand filter would have
269silently deleted it.
270 
271So read `pageName` and `bodyText` on every ad and sort them into three groups:
272 
273- **The brand's own page** — what they are running themselves.
274- **Third parties running the brand's creatives** — affiliates, resellers, agencies. Keep these.
275 Say who they are.
276- **Same-name strangers** — a genuinely unrelated company trading under the same word. The
277 canonical case is "Creatify" the AI tool versus "Creatify.mx" a sticker shop. Drop these.
278 
279**Name the groups in the delivery and say what you dropped and why.** A filter the user cannot
280see is a filter they cannot correct, and the correction is usually one sharper query away.
281 
282### Rank it with the script, not by eye
283 
284```bash
285./scripts/rank-ads.py "$DIR/sweep.json" # the words
286./scripts/make-picker.py "$DIR/sweep.json" # the page
287```
288 
289The first prints the delivery's opening: what the sample is, the twelve picks with their
290basis, who is running them, the metadata-not-craft line. Read it out; do not recompute it.
291 
292**Then get it in front of them, and pick the way by what the session can actually do.** A page
293on disk is invisible in a remote sandbox, over SSH, and to anyone looking at a chat window
294rather than a desktop. **Never say "it is open in your browser"** — you cannot verify that, and
295when it is wrong the user is staring at a screen where nothing happened.
296 
297**If your harness can render an HTML file inline in the conversation** — Claude Code publishes
298one as an artifact — **that is the best answer.** It works locally and remotely, and the cards
299stay clickable. Build it with `--embed`, then publish that file:
300 
301```bash
302./scripts/make-picker.py "$DIR/sweep.json" --embed --out "$DIR/artifact.html"
303```
304 
305Publish it with a title naming the brand and the count, and tell the user plainly that the
306creatives are uploaded to render: they are a competitor's copyrighted work, the page is private
307to them, and the on-disk copy stays local either way. Say it once, not every run.
308 
309`--embed` is not optional there: an artifact's CSP blocks every external host, so a page that
310references the files beside it renders as twelve broken frames. Videos embed as their **poster
311frame** and say so — twelve mp4s as data URIs is roughly 47 MB against a 16 MB ceiling — and the
312script drops the tail rather than exceeding it, saying which on the page.
313 
314**Otherwise fall back to the file on disk**, name its absolute path, and offer to open it. Either
315way, **also put the top few creatives in the conversation** by reading the downloaded images.
316That is the one thing that works in every environment, and it is what makes the page a richer
317view rather than the only one:
318 
319> Top 3 of 12 below. Full sheet: `/abs/path/picker.html` — say the word and I'll open it.
320 
321The second writes a **contact sheet** beside the sweep: twelve creatives at real size, videos
322playable in place, each card numbered and carrying its page, dates and Ad Library link.
323Comparing twelve ads as filenames is not comparing them. Open it and say:
324 
325> 12 candidates, and `picker.html` is open. Click the ones you want, or say **"clone 3"** —
326> or **"clone 1, 4, 7"** for several.
327 
328Cards toggle, nothing starts selected, and the bar copies `clone 1, 4, 7`. **Each number is a
329separate clone run**, so selecting six is six priced runs, not one — the bar says so and the
330estimate still gates every one of them. The ordinal is the contract and the click is the
331convenience, so a blocked clipboard costs nothing. Both scripts share one sort and one default
332— never renumber by hand. A page rather than an option prompt on purpose: a prompt blocks, and
333this flow proposes and proceeds.
334 
335The sort is `collationCount` descending then longest-running, which is arithmetic, and the
336script also decides the thing prose kept getting subtly wrong: **whether the audience-count
337signal is alive at all.** Measured on a real sweep of this category, every row came back `1`
338or `null`, so the field the ordering nominally leads on contributed nothing and run length
339decided everything — while the delivery still recited both bases. The script detects that and
340says "run length only" instead. `--top N` changes how many BOTH produce (12 by default — a
341grid of twelve is one glance where a list of twelve is a scroll); `--json` gives you
342`signalAlive` and the counts if you are formatting your own.
343 
344Three claims it carries that are easy to get wrong by hand:
345 
346- **The SAMPLE is the strongest thing you can say.** The Ad Library is queried with
347 `sort_data[mode]=total_impressions` descending — the same "Impressions: high to low" a human
348 gets in the UI — so every ad in a sweep is among Meta's highest-impression ads for that
349 query. Lead with it.
350- **There is no impressions NUMBER.** Meta publishes per-ad impressions only for political and
351 issue ads, and in the EU. Never quote one, never infer one from position.
352- **The ORDER is ours, not Meta's.** The API re-sorts by `collationCount` then recency before
353 you see it, so position 1 is not Meta's #1. The impressions claim belongs to the sample, not
354 to the order within it.
355 
356What stays yours is the only part no sort can decide: **which of the three is worth cloning**,
357read against the product you are cloning for. And it never becomes a craft judgement — you
358have not watched these ads. Reading one properly is `analyze_ad`, charged **per ad**, which
359against a twenty-ad sweep is an order of magnitude more than the sweep cost. Offer it on the
360top one to three as its own priced step; never quietly upgrade "rank these" into twenty reads.
361 
362### `collationCount` is recurrence, not spend
363 
364It is the closest thing to a spend signal the Ad Library exposes: one creative shown against
365twelve audiences appears as `collationCount: 12`. High collation plus an old `startDate` is the
366strongest available evidence that a creative is working, because nobody keeps paying to run a
367loser for months.
368 
369Two honesties about it. `null` is common and means *not reported*, not zero. And it is not a
370budget: never turn a collation count into a dollar figure or a "they're spending X" claim. The
371Ad Library does not publish spend for commercial ads, and inventing the number is worse than
372leaving it out.
373 
374### Zero
375 
376**An empty sweep is a result. It is charged, and it is correct.** The vendor underneath bills its
377minimum whether or not the query matched anything, so `ads: []` arriving as a `200` means the Ad
378Library genuinely has nothing live for that brand, in that media mode, in that country.
379 
380Say it in one line and move on:
381 
382> No live video ads for BrandX in ALL right now.
383 
384Then stop. Specifically:
385 
386- **Do not apologise for it.** It is often the most valuable line in the whole run — a
387 competitor who has gone dark is a finding.
388- **Do not re-run the same sweep** hoping for a different answer. It is the same query against
389 the same index, and it is a second charge.
390- **Do not quietly widen the country or switch the media mode** to manufacture a result. If a
391 different question is worth asking, ask it out loud, say it is another charge, and get a yes.
392- **Do not pad it** with older creatives, a different brand, or the other media mode.
393 
394This rule is the reason this skill survived its rewrite. The browser-driven version could not
395tell an honest zero from a broken DOM heuristic, and reporting the second as "this brand runs no
396ads" was its worst failure mode. That ambiguity is now gone: a scrape that fails is a
397`provider_failed` (HTTP 502) with the credits refunded, and it never reaches you as an empty list.
398 
399One honest zero has a second cause worth naming when you see it: some ad formats — carousels and
400dynamic creative — are published with no downloadable file on the ad itself, and those are left
401out of `ads`. So a brand whose entire live set is that shape comes back as `ads: []` on a charged
402call. It is still a finding ("nothing fetchable is running right now"), and `adLibraryUrl` on the
403Ad Library search page is where a human can still look.
404 
405## Step 6 — deliver, then offer the clone once
406 
407Deliver per competitor: the files on disk with their paths, the `pageName` groups, and one line
408of what is live versus dark.
409 
410**Say the count first, and say it from the reconciliation in step 4** — "9 of 10 creatives
411downloaded", not "here is their swipe file". If files are missing, that goes in the same sentence
412with the `adLibraryUrl`s for the ones that did not land. A set delivered without its count reads as
413complete, and the user studies four ads believing they are the whole answer they paid for.
414 
415Then offer the next step **once**, in one line, naming a skill that exists in this checkout:
416 
417- A **static** you want rebuilt as your own ads, or turned into a reusable template, or both
418 → `clone-image-ad`.
419- A **video** you want rebuilt for a different product, delivered as a finished MP4
420 → `clone-video-ad`. Hand over the downloaded file path; it reads the source itself.
421 
422Offer, do not start. Either one spends, and neither is what was asked for here. There is no
423hook-only skill in this pack, so an opening on its own routes to `clone-video-ad` too — clone
424the whole ad and cut.
425 
426**Skip the offer entirely when this skill was invoked as a sub-step of something else.** If
427another skill or workflow asked for a swipe file, hand back the file paths **and the ranked top
428three** and stop — the caller owns what happens next, and an offer injected into the middle of
429its flow hijacks it.
430 
431## Step 7 — file the corpus so it is not re-bought
432 
433The sweep is the charge; the files are the asset. Record beside the downloads, in
434`$DIR/sweep.json`'s own directory, what was swept and **when**:
435 
436```bash
437jq -n --arg q "$QUERY" --arg m "$MEDIA" --arg c "$COUNTRY" --arg at "$(date -u +%FT%TZ)" \
438 '{query:$q, mediaType:$m, country:$c, sweptAt:$at}' > "$DIR/swept.json"
439```
440 
441`sweep.json` already echoes the query, media mode and country back, which is why a saved sweep
442is self-describing. The scan **date** is the part it does not carry, and it is the part that
443decides everything below.
444 
445**Before sweeping anything, look for an existing corpus for that brand and media mode.**
446 
447- **Under 7 days old** — reuse it. Do not sweep, do not charge. Say which stored sweep you are
448 using and when it was taken.
449- **7 days or older** — say the date and the age unprompted, offer a refresh **with its price**,
450 and wait. Never refresh silently, and never present stored ads as what the brand is running
451 now. The freshness number is not arbitrary: the other competitor corpus in this repo sat at
452 eight days with no threshold at any age and presented itself as healthy while three filters
453 hid a post the founder found by hand.
454- **Absent** — sweep, after the estimate and the announced total. Reuse is an optimisation on
455 top of the price gate, never a way around it.
456 
457A stored corpus is re-minable: "clone the second one too" costs nothing. The media URLs are dead
458within the hour, but the downloaded files are not, and `adLibraryUrl` never expires.
459 
460## Errors
461 
462`references/errors.md` — every `error.code` this surface emits, what each means, and what to do.
463**Branch on the code, never on the message.** Two that matter before you read anything:
464`provider_failed` (502) is NOT an empty result — the scrape failed and the credits were
465refunded — and a call that times out on your side was probably charged, so reconcile it before
466retrying rather than paying twice.
467 
468## Background
469 
470`references/background.md` — installing this skill outside the pack, and where the retired
471browser-driven version went. Neither is needed to run a sweep.
472 
473## Legal note
474 
475`references/legal.md` — the Meta Ad Library is public, this skill does **not** scrape it (the
476Novoads API does, through a commercial vendor, the same shape as Foreplay or AdSpy), and the
477creatives you download are somebody else's copyrighted work. Studying a swipe file and
478rebuilding an idea is not re-running a competitor's ad.
479 
480## Hard rules
481 
482- Every credit number comes from a live estimate in this session. No rate tables, ever.
483- Never quote a per-ad price. The fee is flat per sweep.
484- Download the media before writing the delivery. `adLibraryUrl` is what you quote.
485- One sweep per competitor, fanned out to the API's ceiling by `sweep.py`, against a list the
486 user has seen and priced.
487- Never ask twice about the same thing, and never with a menu. A question the run can answer
488 for itself is not a question — media mode defaults to `image` and says so.
489- An empty sweep is reported in one line and never retried.
490- Never turn `collationCount` into a spend figure.
491- Never invent an ad, pad a thin result, or deliver a group you filtered without saying so.
492- Close with a ranked top three and the basis for it. Never rank on craft you have not paid
493 to read.
494- Check for a stored corpus before sweeping. Under 7 days, reuse it and charge nothing; older,
495 say its age and price the refresh; absent, sweep after the estimate. Reuse never skips the
496 price gate.
497 

Discussion

Alternatives

Company valuationEstimate the intrinsic value of a public company using DCF, relative (peer multiple) and sum-of-parts (SOTP) methods, then triangulate to an implied share price with upside/downside versus the current market price. Use this skill whenever the user asks: what is AAPL worth", "valuation of NVDA", "fair value of TSLA", "intrinsic value", DCF for MSFT", "build a DCF", "discounted cash flow", "WACC", "terminal value", implied share price", "upside to fair value", "is X overvalued/undervalued", relative valuation", "peer comparison valuation", "EV/EBITDA target", "SOTP", sum of the parts", "how much is [company] worth", "price target from fundamentals", value this company", or any ticker in the context of computing intrinsic or relative valuation. Default to running ALL three methods (DCF + relative + SOTP-if-applicable) and presenting a blended implied price with a sensitivity table. Do not answer valuation questions from memory — always run the workflow. · MITCompetitor gapFind keywords a competitor ranks for that the user's site doesn't — the content gap, prioritized by volume and winnability. Use when asked "what does competitor.com rank for", "keyword gap vs X", "steal competitor keywords", or "why do they outrank us".Business & ops · MITAd angle minerMine the highest-converting ad angles from customer reviews, Reddit complaints, support tickets, and competitor ads. Extracts actual pain language, competitor weaknesses, and outcome phrases that real buyers use. Outputs a ranked angle bank with proof quotes and recommended ad formats per angle.Business & ops · MITCompetitor content intelligenceAnalyze competitor content coverage and uncover differentiated content opportunities using web search, page extraction, and DataForSEO content analysis. Use when asked for competitor content research, editorial gap analysis, content brief inputs, or topic whitespace.Business & ops · Apache-2.0