A weekly competitor sweep that turns Meta and Google ad libraries into an angle inventory, then drafts our own ads against it. Architecture, agent logic, dashboard spec and build plan.
Phase 0 output: the angle taxonomy → Built from 268 competitor ads. Gate passed.
Phase 1: the live intelligence layer → Deployed on Cloudflare. Sweeps weekly, 215 ads held.
The instinct is to build an ad generator with a competitor feed bolted on. That is the wrong centre of gravity, because generation is the part we already solved. There is a working static pipeline that has shipped 247 ads through a ranked template library with brand safety, chroma compositing and a vision QA gate. Adding another image model call is not the hard part.
The hard part is judgment. Ad libraries are a firehose of creative with no performance data attached. Meta publishes no spend, no impressions and no CTR for commercial ads. Google publishes only where and how long. So an agent that "looks at competitor ads and makes similar ones" is copying noise: most of what Klaviyo is running is losing, and we cannot see which half.
The agent's real job is to infer which competitor ads are winning from public signals, decompose those winners into arguments rather than pictures, and then run our own arguments against our own performance data so the loop compounds.
Three consequences follow, and they should drive every build decision:
Before designing anything I probed the access layer. This changes the build materially: most of the plumbing is already paid for and live.
| Capability | Route | State |
|---|---|---|
| Read our Meta ad account: campaigns, ad sets, ads, creatives, insights | treg connected account, meta-ads.* | Live, $0 per call Returns real campaign history going back months. |
| Write to Meta: campaign, ad set, creative, ad, image and video upload | meta-ads.x.campaign-create, .adset-create, .adcreative-create, .ad-create, .account-adimages, .account-advideos | Available, $0 per call No Meta app, no App Review, because the account is connected by OAuth on our side. |
| Competitor Meta ads by page | scrapecreators.x.v1-facebook-adlibrary-company-ads resold through treg | Live at $0.00188 per call on treg's key, which routes around our own expired ScrapeCreators credit. |
| Competitor Google ads by domain or advertiser | scrapecreators.x.v1-google-company-ads, plus serpapi.google.ads.transparency at $0.015 | Live ScrapeCreators is still the only source that returns the YouTube URL behind a Google video ad. |
| Fallback scrapers if a route breaks | Apify facebook-ads-scraper, scrapesage/google-ads-transparency-scraper, and agent-browser for Cloudflare walls | All previously verified |
| Image generation | MagicFit API on GPT Image 2, plus Replicate and Higgsfield | Live Measured cost as low as 2 credits at 1K low, which is production quality for feed statics. |
| Video generation | Seedance, Veo, Wan through treg or Higgsfield | Usable for b-roll and concept films only |
| Our own Google Ads account | google-ads.query, google-ads.accounts exist in the catalogue but with zero samples on our team | Not connected This is the one real blocker. See risks. |
| Our own ScrapeCreators key | direct MCP | Out of credits Irrelevant if we route via treg, which is cheaper anyway. |
One more thing the probe surfaced: the Meta account already contains a campaign literally named DRAFT TEST | ... | 2026-09-03 sitting paused. The draft convention we need is already half in place by accident.
The connected account is PushOwl's, not Brevo's, despite being named "Brevo PushOwl". Brevo runs a second Meta account that is not connected yet. The two are used interchangeably to run ecommerce ads, so this is not a structural problem. Drafts land in the PushOwl account because it is already connected and writeable. Both accounts get tracked for the Compare and Performance screens once the Brevo one is connected.
| Question | Call |
|---|---|
| Tenancy | Single tenant. Brevo and PushOwl are one business; PushOwl is the Shopify-app surface of Brevo, non-Shopify merchants reach the same product via plugins or directly. No multi-tenant data model, which removes a whole class of day-one complexity. |
| Where drafts land | PushOwl Meta ad account, via the treg connection, paused top to bottom. |
| Data sourcing policy | Free routes first. Open scrapers, public endpoints and GitHub skills are the default. Paid endpoints are only justified where a free route cannot return the exact fields at the same quality. Persistence Score depends entirely on delivery start date and active status, so any free route that drops those fields does not qualify and the paid route wins on that field alone. |
| Models | Luna for decompose, the vision-to-schema pass that runs over thousands of creatives and is the highest-volume cost line. Claude Sonnet for all copywriting and brief generation. Sol only for the chat console, the one genuinely multi-step surface. Sweep, score and cluster use no model at all: they are SQL, diffs and embeddings. Models pinned by exact id, never by alias. |
| Existing static pipeline | Reference only. The template library, the fabrication gate and the edge QA logic are read for context and their lessons carried across, but the ad creation pipeline is rebuilt around the teardown method in section 09. The batch-script runner is not lifted. |
| Google Ads | OAuth to be supplied. Until then Google is competitor intelligence only. |
Ten brands, three tiers. Tier 1 and 2 are swept weekly, tier 3 monthly and read for craft rather than positioning.
| Tier | Brands | Why |
|---|---|---|
| 1 · Direct, ecommerce native | Klaviyo, Omnisend, Attentive, Yotpo | Same buyer, same category. Their angle inventory is the one we are fighting over. |
| 2 · Horizontal and adjacent | Mailchimp, ActiveCampaign, AWeber, Sendlane, Drip | Overlapping buyer, different centre of gravity. Mailchimp and ActiveCampaign in particular run large, well-tested creative budgets whose formats travel. |
| 3 · Craft reference | Triple Whale | Not a product competitor. Included because the creative craft in this segment is consistently better than the direct competitors, and formats are portable even when arguments are not. |
Meta gives an ad's start date and whether it is still active. Google gives firstShown, lastShown and shownForDays. That is the whole signal set. An advertiser with a competent media buyer kills losers inside 7 to 14 days, so an ad that has been live for 60 days is almost certainly paying for itself. Everything else we can infer, we infer from behaviour: how many variants they cut of one concept, whether they re-launched something they killed, how many regions they expanded it to. Build the scoring on those, and label it honestly in the UI as an inference, not a metric.
Google's own number_of_ads_estimate is rounded and overstates. Regions listed are where impressions were recorded, not where they targeted, and anything under roughly a thousand impressions is suppressed entirely. Meta keyword search returns other advertisers' ads, so results must always be filtered by page id. One brand routinely has several legal entities with separate advertiser ids, so a domain sweep that finds one entity is an incomplete sweep. Each of these is a silent data-quality bug that will quietly corrupt week-over-week deltas, so each needs an explicit guard in the ingest layer, not a comment in a README.
There is no "draft ad" in the Marketing API. The pattern that works is a real campaign created with status: PAUSED all the way down the tree, in a reserved campaign whose name marks it as agent output, so a human can open Ads Manager, preview the rendered ad, and flip it live. Google Ads is different: it has genuine CampaignDraft objects that stage changes against a base campaign, and it now does real-time policy review on responsive search ads, which means we can get a policy verdict before a human ever looks. Design the delivery layer per platform rather than pretending they are symmetric.
Our sharpest wedge is that Klaviyo charges for profiles you are not emailing. That is also the claim most likely to attract a letter. The agent must be able to generate the angle without naming the competitor by default, tag any variant that does name one, and route those to a human gate that is separate from the normal approve button. This is a switch in the Positioning Constitution, not a prompt suggestion.
One number per competitor ad, recomputed every sweep, from signals we can actually observe. Weights are a starting point to be tuned once we have our own outcome data to calibrate against.
| Signal | What it means | Weight |
|---|---|---|
| Days live, log scaled | Survived the kill decision. The strongest single signal. | 0.35 |
| Still active now | Currently surviving, not historically survived. | 0.15 |
| Sibling count | Number of near-duplicate creatives in the same concept cluster. Buyers only iterate on winners, so a concept with 9 variants is a concept that works. | 0.20 |
| Placement and surface breadth | Feed plus Reels plus Stories, or Search plus Display plus YouTube. Scaling out costs money, so it signals confidence. | 0.10 |
| Geo breadth, with the suppression caveat | Regions where impressions were recorded. Treated as directional only. | 0.08 |
| Resurrection | Went dark, then came back. A strong, rare and underused signal. | 0.07 |
| Spend proxy | Ad count on this concept as a share of that advertiser's total live ads. | 0.05 |
Two derived views matter more than the raw score:
Every ingested ad, static or video, goes through one vision and text pass that returns a structured object. This is the schema that everything downstream depends on, so it is worth over-thinking once and then freezing.
{
"angle": "pricing-fairness", // the argument, clustered
"promise": "stop paying for contacts you never email",
"mechanism": "billing on sends, not stored profiles",
"audience_cue": "scaling DTC brand with a bloated list",
"pain": "bill grew faster than revenue",
"objection": "migration is painful",
"proof_type": "price-comparison | screenshot | stat | testimonial | logo-wall | demo | none",
"offer": "free tier to 100k contacts",
"cta": "compare your bill",
"format": "static | video | carousel | text | display",
"visual_world": "invoice / spreadsheet / receipt",
"hook_text": "Your list is not your bill.",
"hook_seconds": 2.4, // video only
"emotion": "indignation",
"names_competitor": true,
"seasonal_tag": "bfcm"
}
Clustering into angles should be embedding based over the promise plus mechanism fields, with a human-curatable label per cluster. Do not let the model invent a fresh angle name per ad or the inventory becomes unusable within two sweeps.
The outputs that make this worth doing:
The single most important file in the repo, and the one that stops this becoming a machine for producing off-brand slop at scale. Every generated ad is validated against it before it can reach a draft. It is a checked-in document with a machine-readable validator, not a paragraph in a system prompt.
The validator runs twice: on the copy before rendering, and as a vision pass on the rendered image, checking for unauthorised text, invented claims and misspellings. That second pass already exists and already caught a hallucinated money-back guarantee, so it should be lifted straight across.
Do not build one agent. Build six narrow ones behind a queue, each with a single responsibility and a checkable output. The chat console is a seventh that orchestrates them on demand.
For each tracked brand, pull Meta page ads and Google advertiser ads. Diff against what we already hold. Write only new and changed rows. Update last_seen and mark disappearances. Enumerate all legal entities per domain, not just the first. On-demand mode accepts any brand name, domain or page id we have never tracked, resolves it, and runs the same path.
Vision and transcript pass over new creatives only. Statics go to a vision model, Meta videos through the ad transcript endpoint, Google video ads through the YouTube URL then transcript. Emits the angle object above. Costs money, so it runs once per creative, ever.
Recompute Persistence Score across the whole corpus, recluster angles, compute weekly deltas, detect entered and exited angles, write the digest.
Take the top scoring angles, filter for ICP fit against our funnel problem, run them through the Constitution, and emit briefs: the argument restated in our voice, with the hook variants, the proof we are allowed to use, and the format recommendation. This is a text-only loop and it is the one whose output a human should review most closely, because everything downstream inherits its mistakes.
Statics rendered end to end from the existing template library and bake pipeline, with the fabrication gate and edge QA. Videos produce a shootable brief, not a file. Output lands in the gallery as a candidate, never as a draft.
Pull our own Meta insights daily, join live ads back to the angle they descended from, compute per-angle and per-hook performance, detect fatigue, and feed the results back into the Adapt loop's ranking. This is the loop that makes the system compound, and it is the one most likely to get deferred. Do not defer it.
Five tables. Keep metadata in D1 and every binary in R2, because a creative-heavy corpus will hit D1 limits fast and we have already had one read-cap outage on a different project.
| Table | Holds | Notes |
|---|---|---|
brands | Tracked competitors and ad-hoc lookups. Page ids, advertiser ids per legal entity, domains, tier. | Ad-hoc brands flagged so they do not enter the weekly cron unless promoted. |
ads | Every creative ever seen. Immutable body, mutable first_seen, last_seen, is_active, days_live, score. | Never delete. Disappearance is data. |
angles | Cluster label, canonical promise, member count, half life, weekly velocity, whitespace flag. | Human-editable labels. |
creatives | Our output. Links to source angle and source ad. Status: candidate, approved, rejected, drafted, live, killed. Revision history from the feedback field. | The join table that carries all the value. |
performance | Daily snapshots of our own ad metrics keyed on platform ad id. | Snapshot daily rather than query on read, so trend and decay are cheap. |
This is the core of the production layer, and it is deliberately not "show the model a competitor ad and ask for something similar". That produces either plagiarism or mush. Six stages, each with a checkable output.
One vision pass per competitor static returns two objects. The argument layer is the angle schema from section 05. The craft layer is what actually stops the scroll, and it must be captured as structure:
{
"device": "annotated-invoice", // the visual trick carrying the ad
"zones": [
{"role":"hook", "x":0.06,"y":0.05,"w":0.88,"h":0.18,"z":1},
{"role":"artifact","x":0.10,"y":0.26,"w":0.80,"h":0.48,"z":2},
{"role":"callout", "x":0.55,"y":0.44,"w":0.34,"h":0.10,"z":3},
{"role":"cta", "x":0.06,"y":0.86,"w":0.50,"h":0.08,"z":4}
],
"hierarchy": ["hook","callout","artifact","cta","logo"],
"type_system": {"sizes":3,"weight_contrast":"900/400","case":"sentence",
"hook_height_pct":0.14},
"palette": ["#0B1F3A","#F4F1EA","#E23D2C"], // sampled from the pixels
"hook_contrast": 11.2, // ratio against its backdrop
"negative_space":0.31,
"proof_artifact":"invoice line item with a circled overage charge",
"focal_points": 1
}
The lesson from the previous pipeline is load-bearing here: one-line descriptions rendered ads that read as obviously generated, and only a rich zoned spec carrying positions, type system and copy architecture produced work worth shipping. So the teardown is structural from the start.
Strip the brand, the product and the claim. What survives is portable: annotated invoice, one circled line item, single red callout, hook above, sentence case, one focal point. That is a format. Formats are deduped across all ten competitors into a shared bank, each carrying the Persistence Score distribution of every ad built on it. A format used by four competitors with a median 70 days live is a validated container, independent of what anyone put inside it.
Order matters because each step constrains the next.
A re-skin is not the goal. Every adapted ad runs a craft uplift pass with explicit targets rather than vague instruction: hook type raised to the thumbnail-legible floor, exactly one focal point, hook contrast above threshold, fewer distinct type sizes than the source, more negative space than the source, and a rendered world in place of anything that reads as stock. The result is then scored against the source on the same craft rubric, and only variants that beat the source are kept. This is what turns "copy wisely" into something measurable instead of aspirational.
Copy validator before render, against the Constitution and the claims ledger. Vision QA after render for unauthorised text, invented claims, logo fidelity, misspellings and thumbnail legibility. Safe-area and edge check. One hardened retry, then the candidate is dropped rather than shipped degraded.
Each approved brief produces the same argument across several different formats, not several nudges of one. Six to ten finished candidates per brief, so the review step is a real selection rather than take-it-or-leave-it. Sizes: Meta feed 1:1 and 4:5, Stories and Reels 9:16, Google Display in the standard set.
Fully generatable and cheap: 15 headlines and 4 descriptions per responsive search ad, built from the angle's promise and objection fields, plus sitelinks and callouts. Worth mining competitor text ads hard, because that copy is the most honest statement of a competitor's positioning they ever publish, and almost nobody analyses it.
The output object is a shootable brief, and it should be graded on producibility so the pipeline is honest about what it is handing over:
The brief carries: the hook as on-screen text and as spoken line, a beat sheet with timecodes, shot list, b-roll list, the asset request addressed to a human, the source competitor video it was derived from with its timestamped hook, and the aspect ratios to cut. The highest-leverage input here is the competitor hook library built from transcripts: the first three seconds of every high-persistence competitor video, transcribed and clustered. That library is a standalone asset, useful even if nothing else ships.
Nothing reaches a platform without an explicit human click. Approve in the gallery is a review action. Push to platform is a separate, deliberate second action. The agent never pushes on a cron, and never unpauses anything. This is a hard gate in code, not a convention.
Upload image to the ad account image library, create the ad creative, create the ad against a reserved paused campaign and ad set named AGENT DRAFT | angle | date, with status: PAUSED at every level. The human opens Ads Manager, sees the real rendered preview across placements, and either flips it live or deletes it. Record the returned platform ad id on our creative row immediately, because that id is the key the Measure loop joins on later.
Use real CampaignDraft objects for structural changes, and paused ad groups with paused responsive search ads for creative. Upload display images as assets. Take the real-time policy verdict on responsive search ads and surface it in the gallery before a human spends attention on a creative that will be disapproved anyway.
The chat console at the top, occupying the primary position. Below it a compact week strip: new competitor ads, new angles, angles that died, our creatives awaiting review, our live ad count and the one metric that moved most. Below that, this week's digest in prose, written by the agent, three paragraphs, no more.
The gallery of everything ingested. Filters on brand, platform, format, angle, persistence band, active or dead, first seen, geo, names-a-competitor, seasonal tag. Sorts on score, days live, newest, sibling count. Each card shows the creative, days live, score, angle label, and a one-line "why this scored" explanation, because an unexplained score gets ignored. Card actions: adapt this, add to a brief, pin to a board, mute this advertiser.
The inventory. Share of voice stacked by competitor over time, half life table, velocity leaderboard, entered and exited this week, and the whitespace panel. Clicking an angle opens every competitor ad in it plus every creative of ours descended from it plus that lineage's performance. This screen is the product.
Tabbed by status: candidates, approved, drafted, live, killed. Tabs also by platform and format. Every card carries a free-text revision field, and a revision request re-runs only the relevant stage rather than the whole pipeline: copy edits re-render, concept edits regenerate, video-brief edits rewrite the brief. Batch select, batch approve, batch push.
Our live ads beside competitor ads in the same angle. Left column our creative with its real metrics, right column the competitor creative with its persistence signals, labelled clearly as inference not measurement so nobody reads a Persistence Score as a CTR. Below, the angle-level verdict: our CPA on this angle against our own portfolio median.
Meta and Google side by side but never summed, since the metrics are not comparable. Spend, impressions, CTR, CPC, CPA, ROAS where available. Then the cuts that actually inform creative: by angle, by format, by hook, by visual world, by aspect ratio. Plus the fatigue view: frequency against CTR decay slope per ad, with a projected kill date.
Tracked list with tier, plus a field to drop in any brand name, domain or page id for an immediate one-off sweep, and a promote-to-tracked button.
You asked what else is worth tracking. The list below is deliberately longer than v1 needs. Mark the starred ones for phase one and let the rest wait.
Push, not pull. Weekly digest by default, immediate for the sharp ones.
One text box on the home page, an agent loop with tools over the same D1 and R2. It should cover three request classes, and it is worth being explicit about them because they need different handling:
Two design rules. First, every answer that contains a number links to the rows behind it, or the console becomes untrustworthy the first time it is subtly wrong. Second, any action that would write to Meta or Google returns a confirmation card rather than doing it, no exceptions, even when the request sounds unambiguous.
The gates matter more than the phases. Each is a real kill criterion, not a checkpoint to wave through. Phases 1 onward deploy to Cloudflare as they land, so the system is on cloud from the first build phase. Phase 0 is analysis only and writes no code.
| Phase | Ships | Gate to pass |
|---|---|---|
| 0 · Taxonomy shipped | Hand sweep of all ten competitors. Angle taxonomy and seed format bank written by hand and reviewed. No code, no infrastructure. | The taxonomy holds when applied to a brand that was not used to build it. |
| 1 · Intelligence shipped | Cloudflare stack stood up. Ingest, decompose, Persistence Score, clustering, weekly cron. Radar and Angles screens. Google read-only via Transparency. | The angle inventory changes a brief that would otherwise have been written differently. If it does not, stop here. |
| 2 · Production | The six-stage teardown method. Studio gallery with the revision field. Statics end to end, video briefs, Google text ads. Export only, nothing touches a platform. | Six of ten candidates in a batch are ones worth running. |
| 3 · Loop closes | Paused drafts pushed to the PushOwl account. Daily Meta insight ingest across both accounts. The angle join. Compare and Performance screens. | Draft to live conversion above fifty percent. Below that the system is generating work, not saving it. |
| 4 · Google | Our own Google metrics, CampaignDraft and paused responsive search ad push, real-time policy verdict surfaced before human review. | Parity with the Meta path. |
| 5 · Console and alerts | Chat console over the same data, the seven alerts, whitespace mining from reviews and communities. | Every console answer traceable to the rows behind it. |
| Risk | Mitigation |
|---|---|
| Our Google Ads account is not connected yet, so neither our own Google metrics nor Google drafting can work | One browser OAuth on the treg team. Until then Google is read-only competitor intelligence and the Compare screen is Meta only. Worth doing first because it is cheap and unblocks a whole phase. |
| Meta ad library scrapers break, which they periodically do | Three routes already verified. Health-check each on every sweep and fail over rather than failing. |
| Persistence Score gets read as a performance metric | Label it as inference everywhere in the UI. Never put it in the same visual column as a real CTR. |
| Copying angles that do not fit our funnel | ICP fit filter in the Adapt loop, and an explicit kill rule on every adapted test rather than treating a copied angle as proven. |
| Legal exposure on comparative claims | Competitor naming off by default, separate legal gate, claims ledger enforced by the validator. |
| Generated volume outruns human review, and the gallery becomes a graveyard | Cap candidates per week. Track draft-to-live conversion as a first-class metric. If it falls, generate less, not more. |
| Cost drift on decompose, since vision passes over thousands of creatives are not free | Decompose once per creative ever, cheapest model that holds schema, tag every treg call for per-loop cost attribution. |
The four questions this note originally raised are all settled, in the decisions table in section 02. What remains open: