Source profileQuality 91/100Review permissions

novoads/agent-skills/skills/spy-competitor-ads/SKILL.md

spy-competitor-ads

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. One sweep per competitor, priced by a live estimate before anything is charged, media pulled the moment the response lands because the CDN links expire, and every ad carried with its permanent Ad Library URL, the page running it, and how many audiences the creative is running against, closing with the three most established and why. Use when asked

Source repository stars
9
Declared platforms
0
Static risk flags
3
Last source update
2026-08-24
Source checked
2026-08-25

Decision brief

What it does: where it fits

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

Best for

  • Use when asked

Not for

  • Tasks that require unconfirmed production actions or broad system permissions.
  • Environments where the pinned source and install steps cannot be inspected.

Compatibility matrix

Platform support, with evidence labels

PlatformStatusEvidenceWhat to check
CodexNot declaredNo explicit evidencePortability before use
Claude CodeNot declaredNo explicit evidencePortability before use
CursorNot declaredNo explicit evidencePortability before use
Gemini CLINot declaredNo explicit evidencePortability before use
Open the compatibility checker

Installation

Inspect first. Install second.

The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.

Source-detected install commandSource
npx skills add https://github.com/novoads/agent-skills --skill "skills/spy-competitor-ads"
Safe inspection promptEditorial

Inspect the Agent Skill "spy-competitor-ads" from https://github.com/novoads/agent-skills/blob/2f5e03f9c625fd719a1bc5cddaf7f7fe1a6f9e44/skills/spy-competitor-ads/SKILL.md at commit 2f5e03f9c625fd719a1bc5cddaf7f7fe1a6f9e44. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.

Workflow

What the source asks the agent to do

  1. 01

    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.

    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 MASTERCONTEXT.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…Media mode. The API requires it and sweep.py requires it, so SOMETHING has to choose — look for an explicit trigger first:
  2. 02

    Step 2 — price it before you sweep

    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.

    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 ti…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 bac…Then announce, in one line, and get a yes before the first sweep:
  3. 03

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

    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…

    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 downl…--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 rud…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.
  4. 04

    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.

    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 w…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/re…
  5. 05

    Step 5 — read the result with judgement

    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 th…

    The brand's own page — what they are running themselves.Third parties running the brand's creatives — affiliates, resellers, agencies. Keep these.Same-name strangers — a genuinely unrelated company trading under the same word. The

Permission review

Static risk signals and limitations

Reads files

low · line 20

The documentation asks the agent to read local files, directories, or repositories.

**Auth:** `Authorization: Bearer $NOVOADS_API_KEY`, read from `.env` at the repo root. The key

Sends data out

high · line 131

The documentation includes sending, uploading, or posting data to a remote service.

curl -sS -X POST https://api.novoads.ai/v1/estimates \

Network access

medium · line 131

The documentation includes network, browsing, or remote request actions.

curl -sS -X POST https://api.novoads.ai/v1/estimates \

Evidence record

Why each signal appears

EvidenceSourceComputedTestedEditorial
SignalValueEvidence typeMeaning
Quality score91/100ComputedDocumentation, specificity, maintenance, and trust rules
Repository stars9SourceRepository attention, not individual Skill quality
Compatibility0 platformsSourceDeclared in the catalog source record
Usage guideautomated source guideEditorialGenerated or reviewed according to the visible evidence level

Pinned source

Provenance and original SKILL.md

Repository
novoads/agent-skills
Skill path
skills/spy-competitor-ads/SKILL.md
Commit
2f5e03f9c625fd719a1bc5cddaf7f7fe1a6f9e44
License
MIT
Collected
2026-08-25
Default branch
main
View the original SKILL.md

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 The entry offer is a $1 trial. 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 saidmediaType
"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.

Legal note

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.

Frequently asked questions

What to verify before installation and use

What does the spy-competitor-ads source document cover?

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

How do I install spy-competitor-ads?

The source record exposes this install command: npx skills add https://github.com/novoads/agent-skills --skill "skills/spy-competitor-ads". Inspect the command and pinned source before running it.

Which permission-related actions were detected?

Static rules flagged read-files, send-data, network in the source; the page lists the matching lines and excerpts.

Alternatives

Compare before choosing

Computed 10024,921

alirezarezvani/claude-skills

app-store-optimization

App Store Optimization (ASO) toolkit for researching keywords, analyzing competitor rankings, generating metadata suggestions, and improving app visibility on Apple App Store and Google Play Store. Use when the user asks about ASO, app store rankings, app metadata, app titles and descriptions, app store listings, app visibility, or mobile app marketing on iOS or Android. Supports keyword research and scoring, competitor keyword analysis, metadata optimization, A/B test planning, launch checklist

Computed 9923

indranilbanerjee/contentforge

cf-variants

Generate 3-10 scored A/B test variations of a single content element — headline, hook, CTA, intro, or conclusion — each rated across 6 quality dimensions and ranked by your optimization goal (clicks, engagement, conversions, or readability), with top-3 recommendations and A/B test setup guidance (sample size, duration, success metric). Triggers on "/contentforge:cf-variants", "give me headline alternatives", "A/B test options for this CTA", "which hook is stronger", "write 5 versions of this int

Computed 9867

SerendipityOneInc/ZooData-Skills

zoodata

API endpoint reference for the ZooData data platform: the 12 commerce endpoints plus 10 keyword-intelligence endpoints (categories, markets, products, competitors, realtime ASIN, AI review analysis, raw reviews, price band, brand, history, and the keyword detail/trend/extends/search/ market-profile/product-traffic/competitor-keywords/traffic-profile/ traffic-timeline family) — their inputs/outputs, parameter quirks, Quick Start (auth, base URL), how credits are tracked (meta.creditsConsumed), an

Computed 9830

MoizIbnYousaf/marketing-cli

higgsfield-generate

Use when the user wants to generate an image or video via Higgsfield AI. Covers 30+ models: Soul V2, Seedance 2.0, Kling 3.0, Veo 3.1, GPT Image 2, Nano Banana 2. Also covers Marketing Studio — branded ad video/image with avatars and products. Use whenever: "generate an image", "make a video", "animate this photo", "image-to-video", "img2vid", "edit this image with AI", "produce a clip", "create an ad", "make a UGC video", "marketing video", "brand video", "TV spot", "import product from URL", "