Best for
- Use when asked
novoads/agent-skills/skills/spy-competitor-ads/SKILL.md
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
Decision brief
Find what a competitor is actually running, and put the files on disk. That is the whole job.
Compatibility matrix
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
Installation
The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.
npx skills add https://github.com/novoads/agent-skills --skill "skills/spy-competitor-ads"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
Four things decide a run. Take each in order, take it in one line, and only ask about the ones you genuinely cannot resolve.
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.
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…
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.
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…
Permission review
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 keyThe documentation includes sending, uploading, or posting data to a remote service.
curl -sS -X POST https://api.novoads.ai/v1/estimates \The documentation includes network, browsing, or remote request actions.
curl -sS -X POST https://api.novoads.ai/v1/estimates \Evidence record
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 91/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 9 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated or reviewed according to the visible evidence level |
Pinned source
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.
https://api.novoads.ai/v1 (host overridable with NOVOADS_BASE_URL — host
only, you append /v1).Authorization: Bearer $NOVOADS_API_KEY, read from .env at the repo root. The key
is novo_ plus 64 hex../scripts/check-novoads-env.sh.env, or paste a key into MASTER_CONTEXT.md.REST key required. A Novoads MCP connector is not a substitute. If
NOVOADS_API_KEYis 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 whenmcp__novoads__*tools are connected and authenticated in the session. Never callmcp__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 checksNOVOADS_API_KEYin 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.
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.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.200 OK. See Reading the result.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.
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.
./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.
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: [].
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.
pageName is who is running the ad, not who makes the productNothing 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:
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.
./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.htmlis 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:
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.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 spendIt 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.
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:
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.
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:
clone-image-ad.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.
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.
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.
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.
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.
adLibraryUrl is what you quote.sweep.py, against a list the
user has seen and priced.image and says so.collationCount into a spend figure.Frequently asked questions
Find what a competitor is actually running, and put the files on disk. That is the whole job.
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.
Static rules flagged read-files, send-data, network in the source; the page lists the matching lines and excerpts.
Alternatives
alirezarezvani/claude-skills
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
indranilbanerjee/contentforge
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
SerendipityOneInc/ZooData-Skills
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
MoizIbnYousaf/marketing-cli
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", "