---
name: build-ads
description: Build a complete Google Ads search campaign for a test page on smartatest.se out of what the page already publishes — campaign, ad group, responsive search ad, phrase keywords, deep-linked sitelinks, callouts, structured snippets and price assets. Takes no argument to walk every eligible live page, a slug for one page, a category key for a group, or `auto` to run without stopping. Writes one JSON per page to content/annonser/ and never edits a CSV by hand, always exports the Google Ads Editor CSV, and can push straight into Google Ads over the API after showing a read-only diff — asking each run whether the campaign goes up paused, enabled or not at all, with paused the default and the ad group always paused. `update` reconciles the three places a value lives, in order: refresh derived prices and URLs from lib/data, pull hand-edited copy back from the Google Ads account, then push the merged result and re-export the CSV. Use when asked to build, rebuild, refresh, sync or update search ads, ad copy, ad assets, keywords, sitelinks or price assets for a page, to pull hand edits back out of Google Ads, or to push existing plans to Google Ads.
---

# Build the search ads for a page

One `content/annonser/{slug}.json` per test page. That file is the source of
truth; `pnpm annonser:export` turns every page into **one** Google Ads Editor
file and `pnpm check:annonser` verifies the JSON.

**Never write a CSV by hand and never edit one in the export folder.** The
export directory is regenerated on every run and lives under `.agent/`, which
is gitignored. Anything typed there is lost.

## Naming, and why it is not decorative

| | Value |
|---|---|
| Campaign | the slug, lowercase — `brandvarnare`, never `Smartatest \| Brandvarnare` |
| Ad group | `comparison` |
| Account | from `content/annonser/_konto.json`, stamped on every row |

The account stamp is a safety rail, not bookkeeping. Editor imports into
whichever account a row names, and on 2026-08-08 an export with no account
column went into 669-303-1203 and was posted before anyone noticed. The export
script refuses to run when `_konto.json` has no account.

The decisions behind every rule here are in `.agent/plans/google-ads.md`. Read
that file when a rule seems arbitrary — it records what was rejected and why.

## Read first

| File | Why |
|---|---|
| `references/rsa-regler.md` | What makes an ad strong, and the two rules that are ours |
| `references/slotplan.md` | The eleven templated headlines and their fallback ladders |
| Skill `swedish-voice` | Before writing a single reader-facing line |
| `.claude/references/page-runs.md` | The argument grammar and the per-page question |

`page-runs.md` owns the argument grammar and the mechanics of the question.
This file does not restate them. What this skill adds is below.

## Which pages

Eligible: **live test pages** in `lib/catalog.ts`. Category pages, tools and
utility pages have no products, no awards and no prices, so they have nothing
to build a price asset from. Naming one directly should say so and stop.

### No page named? Ask from the queue

**`.agent/google-ads-korlista.md` is the queue this skill draws from.** One row
per live test page, built or not, with what a campaign on it would earn or cost:
comparison volume, bid, the winner's shop and rate, how many of the page's links
pay, BE Conv. rate, ROAS and modelled net.

Invoked with no page, **read it and ask**. Put the four or five topmost 🟢 rows
in an AskUserQuestion, and **put the numbers in the option text** — volume, ROAS
and net per month — so the choice is made on the figures rather than on which
subject sounds interesting. Same shape as `/new-page`'s question out of
`.agent/testsidor-tackning.md`.

**Named directly, build it. No gate, no second question, not even at ROAS 0,3.**
Write the number into the report so the price is visible, and carry on. The
queue is there to inform a choice, never to overrule one — `CLAUDE.md`'s default
on a judgement call is yes with the cost stated beside it.

⚠️ **The queue's figures are dated and nothing keeps them current.** It was
written by hand and carries the date it was written. Prices, rates and which
shop a link points at all move, and `money.md` records three separate times that
a number-table about the shop situation is wrong within days. **Recompute the
row you are about to act on** — the file names its own sources per column — and
say in the report which figures you re-derived and which you took as written.

Two things to maintain, in the same pass as the build:

- Set the row to ✍️ when the build starts and `- [x]` when the campaign is in
  the account.
- **A page you refuse, or one the numbers kill, keeps its row** with the reason
  written on it. Deleting it means the next round re-derives the same finding
  from scratch, and the file would drift into a list of what was already easy.

### A cluster child has a nested URL and a flat slug

`/vaxthus/fristaende` lives in `app/vaxthus/fristaende/page.tsx`, but its data
file is `lib/data/vaxthus-fristaende.ts` and `export-annonser.mjs` writes
`{slug}-{datum}.csv`. **A slug with a slash would write into a subdirectory
that does not exist**, so the slug is flat and the URL is not:

```
slug        vaxthus-fristaende          file, campaign name, CSV name
sida.url    /vaxthus/fristaende         every sitelink and price row
```

⚠️ **Build every asset URL out of `sida.url`, never out of the slug.**
`/vaxthus-fristaende#kopguide` is a 404 that nothing on our side would catch.

⚠️ **This broke a check silently on 2026-08-10.** `lasSida()` looked in
`app/{slug}/` only, found nothing, and returned an empty anchor set — so rule 7
and the anchor check reported green without reading the page at all. It now
falls back to `sida.url`. If you add a lookup keyed on the slug, give it the
same fallback, and prove it with a fixture that should fail.

A page that already has a `content/annonser/{slug}.json` is a **rebuild**. Say
so, show what changes, and never silently overwrite.

## The order

### 1. Read the page

Everything comes from the repo. Nothing is invented, and nothing is carried
over from another page.

| Read | For |
|---|---|
| `lib/test-pages.ts` | `label`, `title`, `criteria`, `category` |
| `app/{slug}/page.tsx` | `metadata.description`, `UPDATED`, the `TOC` array, the ingress |
| `lib/data/{slug}.ts` | `PRICE_CHECKED`, and per product `id`, `brand`, `name`, `shortName`, `price`, `merchant`, `award`, `superlative`, `tagline` |
| `.agent/keywords/utfall.md` | Verified synonyms for the topic word |

⚠️ **Read the prices out of `lib/data`, never out of the page's prose or an
older ad file.** Rule 5 exists because they drift, and a wrong price in an ad
is the one error a reader can act on before we notice.

⚠️ **Round in the copy, never in `priser`.** `swedish-voice` rounds a price in
prose to whole kronor, so a headline reads `Testvinnaren kostar 1 350 kr`.
`priser[].pris` is a structured field and takes the figure from `lib/data`
untouched, so the same page carries `1349.9` in the price asset. Both are right
at once.

Rule 5 compares the asset against the data file and fired **four times in one
run** on `/alkoholmatare`, where the rounded figure had been carried into the
price rows. It will fire on every page that links InkClub, whose whole range is
priced on `,90`.

### 2. The keywords

**Every keyword is a comparison word combined with the topic word or a verified
synonym.** Nothing else. The comparison words are `JAMFORELSEORD` in
`lib/annons-schema.mjs`; the topic word is the page's own category term.

Four to ten phrase keywords per page. All `phrase`, never broad, never exact.

**Never generate:**

- the bare category term on its own — it carries pure buying intent on a
  competitive SERP, which is the most expensive and least qualified traffic a
  comparison page can buy
- any manufacturer or model name
- both word orders of the same phrase — Planner normalises word order, so
  `bäst i test X` and `X bäst i test` are one search
- `{term} 2026`, `{term} guide`

⚠️ **A gender or inflection gap in `JAMFORELSEORD` is fixed in the list, never
with an `undantag`.** The list is Swedish, and Swedish inflects: it carried
`bästa` but not `bäst` until `/vibrationskudde`, and `vilken` but not `vilket`
until `/taktalt`. Both times rule 8 felled a keyword that was perfectly correct
Swedish — `vilken taktält` is not a phrase, because `taktält` is an ett-ord.

An `undantag` is for a trade somebody weighed, not for a word the list happens
to lack. Writing one there hides the gap and makes the next page with a neuter
category word write the same exception again. **Add the form to
`JAMFORELSEORD` with the reason beside it, and say so in the report.**

## Volume comes from the keyword store now

⚠️ **Ask `node scripts/sokord.mjs sida` first, before `utfall.md` and before any
Trends sweep.** Since 2026-08-12 every live page's grid is measured there, with
the source and date on each row, and a fresh measurement costs $0.09 per thousand
keywords. The skill `keyword-research` owns the store; `dataforseo` (global) owns
the provider.

⚠️ **This section said `content/sokord/master.csv` until 2026-08-15, and that
directory does not exist.** The data moved into redpoint9's Neon database the same
afternoon the CSV was written, so every reference here to *the file* means the
store behind `scripts/sokord.mjs`. Read it with the commands below, never by
opening a path.

```bash
node scripts/sokord.mjs sida /vapenskap   # volym, CPC-spann, intent, kd, cps
node scripts/sokord.mjs omatta            # vad som saknas, och vad det kostar
```

⚠️ **I Git Bash på Windows måste `MSYS_NO_PATHCONV=1` stå före kommandot.** Det
ledande snedstrecket i slugen skrivs annars om till en Windows-sökväg innan
skriptet ser argumentet, så `sida /lamineringsmaskin` blir
`sida /C:/Program Files/Git/lamineringsmaskin`.

```bash
MSYS_NO_PATHCONV=1 node scripts/sokord.mjs sida /lamineringsmaskin
```

**Det som gör felet dyrt är att det inte kraschar.** Svaret blir
`Inga anspråk för … Kör seed, eller kontrollera slugen`, vilket läser som en
riktig mätning: sidan är omätt, kör seed. Alltså exakt *frånvaro av en mätning
förväxlad med en mätning av frånvaro*, det fel `money.md` dokumenterar tre
gånger. PowerShell-verktyget rör inte argumentet och behöver ingen prefix.
**Ser du `/C:/Program Files/Git/` i ett felmeddelande, är det den här fällan
och inte en omätt sida.**

Three consequences for this skill:

- **"No Planner row" is no longer a reason for `volym: null`.** A page built after
  a round is measured by running `node scripts/sokord.mjs seed` and then
  `volym --kor`. Do that instead of falling back to a Trends sweep.
- **A blank volume in the store still has to be read correctly.** Empty
  `volym` with a `kalla` means Google has no figure for the term — measured, no
  data. Empty with no `kalla` means nobody asked. Only the second is a task.
- **Negative keywords belong in the same store**, column `polarity`, value
  `negative` — not `polaritet: negativ`, which was the CSV's spelling and never
  the database's. `sida` set means a page's own, empty means the sitewide list.

  ⚠️ **`sokord.mjs` has no command that writes one, so today they only reach
  Google.** `/ekolod` 2026-08-15 wrote sixteen page negatives into its own JSON;
  the push and the CSV carried them and the keyword store never heard of them, so
  the next page in the field inherits nothing. Say so in the report rather than
  claiming the words were recorded, and treat the missing command as a task.

  ⚠️ **The same hole swallows a fresh measurement, and that is worse.** A negative
  word can be re-derived from the page's own scope in a minute. A *measured volume*
  cost money and cannot be re-derived at all — it has to be bought again.
  `/elscooter` 2026-08-15 measured the whole `elsparkcykel` family for 0,09 USD,
  found **49 500 against the slug's 27 100**, and the store still has no row for
  the word: `sokord.mjs` can seed a grid and measure what it seeded, but it cannot
  take a keyword somebody hands it. The figures survive only in
  `content/annonser/elscooter.json` and in that run's report.

  **Write every new measurement into the report with its task id**, so the next
  run can find the response rather than pay for it twice, and treat *a command that
  writes one keyword* as the task both halves of this gap are waiting for.

⚠️ **Read the bid out of the script, never out of a research file.** The `BUD`
column in `node scripts/sokord.mjs sida` is `bid_low` already converted with
`tillKronor()`. DataForSEO answers in **dollars**, and a research file written
during the page build has usually copied the raw figure down without the
currency — where it is indistinguishable from kronor.

`/lampa-med-rorelsesensor` 2026-08-12 is the case, and the error reached the
user before the ad run began. The page's own research recorded `CPC 0,32–0,60`
for the head term, so the BE Conv. rate was reported as **0,39 to 0,73 percent**.
The real bids are 3,06 to 5,76 kronor and the real figures **1,17 to 3,71
percent**, alltså 9,6 gånger fel. The conclusion inverted with them: the head
term does not pay at all, and the cheap tier pays well.

One `node scripts/sokord.mjs sida /{slug}` costs nothing and is the only
number the campaign may be built on.

`utfall.md` and the Trends routes below stay as history and as the fallback when
the API is down. The rest of this section is that fallback.

**Synonyms come only from `utfall.md`'s cluster tables.** Has the page no
verified synonym, it runs on its topic word alone. A synonym that is obviously
right but unmeasured is a task for the next measurement round, not a licence to
guess — say so in the report.

⚠️ **A page built after the last Planner round used to be the exception, and the
rule above would leave it on one word.** `utfall.md` covers what was measured, not
what exists, so a page that did not exist when the round ran has no entry there.
That is absence of a measurement, not a measurement of absence — the same fault
`money.md` records three times. **The master file removes the excuse: measure it.**

Where the page's own research carries a Google Trends sweep of the neighbouring
terms, **those terms may become topic words**, on three conditions: the figures
go in the report and in `sida.amnesord`, every keyword built on them carries
`volym: null`, and the trade is put to the user rather than taken silently.

`/tv-husvagn` 2026-08-11 is the case. Built the same day as the page, after
round 2, so `utfall.md` had nothing. The page's own sweep measured `12v tv`
3,05, `tv husbil` 2,48, `camping tv` 2,25 against the slug's `tv husvagn` 1,90
— so the rule as written would have run the campaign on the *fourth* biggest
term in its own field. The user took all of them plus `tv båt`.

⚠️ **Trends is not Planner and the report has to say so.** It gives relative
interest and no volume, so `volym` stays `null` and the page still counts as
having no known demand.

⚠️ **A sweep stopped by an exhausted quota is a third state, and the report has
to name which of the three it is.** *Measured at zero*, *never attempted* and
*the quota ran out* all end in `volym: null`, and only the last one tells the
next round to try again. On `/alkoholmatare` both routes were blocked the same
afternoon: `google-trends-api` answered a captcha page and SearchAPI returned
`429 You have used all of the searches for the month`. Write which one you hit.
Same family of fault as the three `money.md` records under *absence of a
measurement is not a measurement of absence*.

⚠️ **A sweep the page build could not complete is not a sweep that failed. Run
it again here.** Google Trends answers a captcha under load, and a research file
that says the term is unmeasured may only be recording that one afternoon. Check
what the research actually says before you put the page on one topic word: *we
looked and there was nothing* is a finding, *the tool refused three times* is a
retry.

`/namnlappar` 2026-08-11 is the case, and it is the same day as `/tv-husvagn`.
The page build hit `/sorry/index` three times and wrote the term down as
unmeasured. The identical sweep went through on SearchAPI a few hours later and
measured `namnlappar` 32,25 against `namnlappar barn` 4,64, `namnetiketter`
0,15, `stryklappar` 0,02 and `klädmärkning` 0,00. The rule as written would have
run the campaign on one word; the user took all five.

⚠️ **Budget the quota, and run the free route first.** `google-trends-api` sits
in `node_modules` and costs nothing at all; SearchAPI allows 100 searches a
month and one Trends call spends one of them. The free package answers a captcha
more often, which is exactly why it goes first: a captcha costs a second of wall
clock, and a SearchAPI call spends a search whether the page needed it or not.

```js
const gt = require("google-trends-api");
gt.interestOverTime({ keyword: ["term", "synonym"], geo: "SE",
                      startTime: new Date(2025, 7, 1) });
```

Anchor every batch on the page's own topic word so the batches are comparable,
and expect to get one batch, not three. `/babyvakt` and `/alkoholmatare` both
hit both walls the same afternoon on 2026-08-11 — the free route answered a
captcha page and the quota was already spent — so neither page could measure a
synonym at all.

⚠️ **When Trends is unavailable on every route, autocomplete is the reserve —
and it is not in any other file.** `/laserskarare` 2026-08-11 is the case:
Google Trends answered **429 five times** across two sessions, SearchAPI's
monthly quota was spent, and the retry the rule above demands was run and
refused too. The page would have gone to the topic-word question with nothing.

```bash
curl -s "https://suggestqueries.google.com/complete/search?client=firefox&hl=sv&gl=se&q=lasersk%C3%A4rare"
```

Free, no key, no quota, and it answers the one question the topic-word question
needs: **is this a phrase people actually type?** `laserskärare` returned
metall, trä, hobby, kth, stockholm, plexiglas, pris and begagnad. `lasergravyr`
returned place names, so a service search. `gravyrmaskin` returned biltema and
clas ohlson.

Four of that page's eighteen negatives came straight out of those lists, and the
whole argument that the bare category term carries research rather than buying
intent came out of them too.

**It gives no volume and no ranking**, so `volym` stays `null` and the page still
counts as having no known demand. It separates a word that lives from a word that
does not, which is what the question needs and all it needs.

⚠️ Percent-encode the query, and decode the answer: the response is latin-1, so
`ä` arrives as `?` if you read it as UTF-8.

⚠️ **Do the decoding in node, not with `iconv`.** On `/lamineringsmaskin`
`curl … | iconv -f latin1 -t utf8` returned an **empty string on all five
prefixes, with no error and exit 0** — so the sweep read as "Google has no
suggestions for this word", which is a finding, when in fact nothing had been
decoded at all. The same requests answered 200 with a full payload. This recipe
works:

```js
const https = require("https");
https.get("https://suggestqueries.google.com/complete/search?client=firefox&hl=sv&gl=se&q="
  + encodeURIComponent(q), r => {
  const b = []; r.on("data", c => b.push(c));
  r.on("end", () => console.log(JSON.parse(Buffer.concat(b).toString("latin1"))[1].join("\n")));
});
```

**Check the raw bytes before concluding a word has no suggestions.** One
`curl -s -w "\nHTTP:%{http_code} BYTES:%{size_download}\n"` separates an empty
answer from an empty decode, and the difference is the whole conclusion.

### Two pages in a cluster may share a topic word

A mother and a child, or two children, will often want the same term:
`/vaxthus` and `/vaxthus/fristaende` both live off `växthus`. **Build both, and
do not raise it.** Settled by the user 2026-08-10: two campaigns on the same
phrase is a landing-page test, and which one gets the impression is decided in
the Ads account rather than here.

That makes it a bidding decision and not an editorial one, which is the whole
reason it is not ours. Do not deduplicate keywords across a cluster, do not
narrow a child's terms to avoid its mother, and do not put the overlap in the
report as a caveat.

### The bare term goes in unless one of the tests refuses it

⚠️ **This section said *refusable, not forbidden* until 2026-08-11, and that
described the exception as an exception. It is the default.** Five of eleven
pages carry it, and on two of them — `/stavmixer` and `/namnlappar` — the user
took it against the recommendation in the question. Write the question that way:
the bare term is in, and the three tests below are the reasons it might not be.

The rule against the bare category term rests on one claim: that it carries
buying intent a comparison page cannot serve. **Check whether that claim holds
on this page before applying it.** On `/vapenskap` it did not — nobody impulse-
buys a licence-controlled cabinet for 8 000 kronor, so the bare term carries
research, and comparison intent measured 50 a month against 5 400 for the term
itself. The user approved the exception on 2026-08-09.

**A page that takes the bare term usually needs page-specific negatives.** The
broad word drags in the adjacent meanings the page does not serve, and the place
to name them is `negativa` on the page's own JSON, never `_negativa.json` —
that list is written to every campaign. `/blender` shut out the 3D software;
`/namnlappar` shut out `namnskylt`, `namnskyltar`, `konferens`, `bröllop` and
`mall`, that is office badges, wedding seating cards and printable templates.
Read the page's own methodology footnote first: it usually names the neighbouring
product the page deliberately does not rank.

⚠️ **Read `_negativa.json` before you write the page's own list.** The site-wide
file already holds the obvious ones, and a word in both places is a duplicate
that rule 2 fails. `/laserskarare` wrote eighteen and four of them — `hyra`,
`service`, `begagnad`, `blocket` — were already there, which cost a check round
that one `grep` would have saved.

⚠️ **Put the page's negatives inside the recommended option, not after the
answer.** The question has been asked about the term alone and the negatives
added afterwards, which decides the term without showing what keeps it clean.
On `/alkoholmatare` the five words — `hembränning`, `areometer`, `spritmätare`,
`mätglas`, `alkolås` — stood in the recommended option's text with
*take it, without negatives* as its own choice, and the package was taken.

The words came out of the page's own scope: the catalogue comment names both
the home-distilling hydrometer and the alcohol interlock as goods the page
deliberately does not rank. Read that comment first, then write the words into
the option.

An approved exception is written into the keyword as `undantag` with the
reason. `check:annonser` then reports it as a **notering** rather than failing
it, so it stays visible in every future run instead of turning into an
unexplained keyword nobody dares touch.

```json
{ "text": "vapenskåp", "match": "phrase", "volym": 5400,
  "undantag": "Rent kategoriord med flit. 5 400 mot 50 i jämförelseintention …" }
```

#### `Ja, men pausat` är ett stående alternativ i den här frågan

⚠️ **Lägg alltid fram *ta ordet, men pausat* som ett av alternativen.** Peters
ord på `/soundbar` 2026-08-15, ordagrant: *"lägg in de rena kategoriorden som
pausade (detta borde vara ett av valen alltid (för självinlärningen))"*.

Det är en tredje nyckel ovanpå de två som redan finns. Kampanjen kan svaras
`Aktiverad`, annonsgruppen är alltid `PAUSED`, och **ett pausat sökord kostar
noll även när båda de andra slås på**. Ordet skapas, Google granskar det, och det
serverar först när en människa släpper fram just det. Det gör frågan billig att
svara ja på: hela volymen ligger kvar i kampanjen som en knapp, i stället för att
gå förlorad i ett nej.

Skrivs som `status` på sökordsraden. Utelämnat fält betyder `Enabled`, alltså är
varje sökord skrivet före 2026-08-15 oförändrat.

```json
{ "text": "soundbar", "match": "phrase", "volym": 12100,
  "status": "Paused",
  "undantag": "Rent kategoriord med flit … Pausat på sökordsnivå." }
```

⚠️ **Fältet fanns inte förrän det behövdes, och tre filer hårdkodade `Enabled`.**
`scripts/export-annonser.mjs` skrev `fyll(r, "Status", "Enabled")`,
`redpoint/lib/track/ads-push.ts` skrev `status: "ENABLED"`, och plantypen i
`ads-campaigns.ts` bar inget fält alls. Utan alla tre hade planen burit `Paused`,
`check:annonser` gått grön och kontot fått ett aktivt sökord — instruktionen hade
tystnat på vägen utan att lämna ett spår. Samma familj som `negativa`, som fanns
i planformatet i fem dagar innan pushen kunde se det.

⚠️ **`ads:diff` jämför sökord på `matchType:text` och ser inte statusen.** Fältet
avgör vad en *skapning* skriver. Ett statusbyte på ett sökord som redan ligger i
kontot är osynligt för diffen och måste göras för hand i gränssnittet.

⚠️ **Följden är att pushen är det enda tillfället statusen går att kontrollera,
och den rapporterar den inte.** `ads:push` skriver `49 operations` och säger
ingenting om hur många sökord som blev pausade — precis som `.slice(0, 8)` inte
säger vad den kapade. Diffen kan aldrig ta igen det efteråt. **Fråga kontot direkt
efter en push där ett sökord ligger pausat**, annars är instruktionen obekräftad:

```
SELECT ad_group_criterion.keyword.text, ad_group_criterion.status
FROM keyword_view WHERE campaign.name = '{slug}'
```

`/elscooter` 2026-08-15 kördes så och gav **två pausade, femton aktiva**, alltså
exakt de två rena kategoriorden. Det tog en minut och är skillnaden mellan att
veta och att anta — och antagandet hade varit rimligt, eftersom både exporten och
pushen bevisligen bär fältet. Det som gör kontrollen värd tiden är att den enda
kontroll som annars skulle fånga ett fel, diffen, är blind för just det här.

**A third case, and it is the weakest of all: the category word is also the
name of a method or a technique.** `sous vide` is French for a way of cooking
before it is a thing on a shelf, so the bare term carries recipes, times and
temperatures and `vad är sous vide`. That is neither buying intent nor research
before a purchase — it is a reader who wants an instruction and will bounce off
a comparison page. Where the topic word doubles as a technique, a material or a
process, the exception argument does not apply and the term stays out. Measured
against the two cases above on `/sous-vide` 2026-08-09.

Raise the trade with the user; never take it silently.

### En refuserad ordfamilj blir en körad, i samma pass

⚠️ **Refuserar du en ordfamilj för att sidan täcker för lite av den, skriv raden
i `/new-page`-kön innan du går vidare, och sätt ut prislappen.** Ett nej som bara
lever i en negativlista lämnar efterfrågan utan spår i någon fil, och nästa
mätrunda hittar samma ord och vet inte att någon redan vägt dem.

Det är `CLAUDE.md`s *hinder är uppgifter, inte domar*, tillämpat på ett sökord.
Att sidan rankar för få av en undertyp är **operativt**: det löses av fler
produkter eller av en egen sida, alltså av arbete, och inte av att ordet är fel.

`/skrivare/laser` 2026-08-15 är fallet, och Peter bad om köraden i samma andetag
som han tog bort orden. Färgtermerna mäter **2 610 i månaden utan ett enda
märkesord** — `laserskrivare färg` 1 300, `färglaserskrivare med scanner` 720,
`färglaserskrivare` 590 — och sidan rankar en färglaser av tolv, så
`bästa färglaserskrivaren` hade lovat en jämförelse som inte finns. `färglaser`
och `färglaserskrivare` gick in som negativa **och** `/skrivare/farg` gick in
som eget 🟢-block högt upp i `.agent/testsidor-tackning.md`.

Skriv in tre saker i köraden, annars är den bara en åsikt: **den uppmätta
volymen per ord**, **vad som redan finns av underlaget** — här är färgmaskinen
redan researchad och omdömesskriven på lasersidan — och **vad som inte går att
ärva**. Det sista bär oftast hela kostnaden: en färglaser har fyra
förbrukningsvaror mer än en monolaser, så kostnadskriteriets skala måste räknas
om från grunden och kan inte kopieras.

### Problemintention: när kategoriordet är fackspråk

A third intent sits beside buying and research, and it only appears on pages
where the category word is a term of art. **The buyer does not yet know what
the product is called, so they search the problem instead.**

`/vibrationskudde` is the case. Nobody types `vibrationskudde` until somebody
has told them the word; the person who has just discovered that a parent
cannot hear the smoke alarm searches `brandvarnare för hörselskadade`. That
phrase carries no comparison word at all, so rule 8 rejects it, and it is
still the phrase the page exists to answer.

Two conditions, and both have to hold:

- **The category word is jargon**, not something a buyer would arrive with.
  Test it by asking who taught them the word. `kassaskåp` and `airfryer` fail
  this — everyone already has them.
- **The problem phrasing names the product class**, not a symptom.
  `brandvarnare för hörselskadade` is a shelf; `hör inte larmet` is a
  complaint and would buy unqualified traffic.

Approved rows carry `undantag` with the reason and `volym: null` until a
Planner round measures them, exactly like the bare-term exception above. Raise
it with the user; never add one silently.

Record measured volume per keyword in `volym`, or `null`. `RAPPORT.md` sums it,
and a page on `null` has no known demand to meet.

### 3. The ad

Eleven templated headlines from `slotplan.md`, four written under
`swedish-voice`, four descriptions. `references/rsa-regler.md` governs.

**Descriptions carry the comparison words the headlines do not.** If the
headlines are heavy in `bäst i test`, the descriptions lead with `bästa`,
`jämför`, `jämförelse`, `guide`, `se vinnaren`, `vilket blev bäst`. The ad
covers more query variants that way, and the four descriptions stop reading
like four versions of the same line. The year and sometimes the month may
repeat. Instructed by the user 2026-08-09.

⚠️ **Never state a bare fact about the winner without framing it.** `Två lådor
ger 1 235 watt i varje` and `Under 150 kilo måste skåpet bultas fast` read as
floating trivia — the reader has no idea whose watts or which cabinet. Either
say outright that it is the winner being described (`Vinnaren tar sexton gevär
när licensen tar tio`) or **sell the comparison itself** (`Hela jämförelsen på
ett ställe: tio skåp, fem kriterier, en vinnare`). The user rejected three
candidates in a row for this on 2026-08-09.

**Every description ends in a call to action.** `Läs den innan du köper` and
`Se det vinnande skåpet här` both landed; a description that just states a
fact does not.

⚠️ **Say what the buyer gets, never what the criterion is called.** A criterion
label is the writer's word for the thing, arrived at after reading the field.
The reader meets the description before the page and has no idea what it means.

`/brodrost` 2026-08-11 is the case, and it is the user rewriting a description
by hand. `rankade på värme per skiva` became **`rankade på hur bra de rostar
bröd`** — and it stayed there after the objection was put to him that heat 25
plus control 20 is 45 of the page's 100, while capacity, build and value carry
the rest. He took the less precise line with both alternatives in front of him.

The trade is real and it is his to make: `värme per skiva` is the page's own
finding and its heaviest criterion, and it still says nothing to somebody who
has not read the page. **Write the criterion into the description as an
outcome, not as a label.** Where the plain wording would overstate what the
ranking covers, say so in the question and let the user decide — do not
silently narrow the claim, and do not silently widen it either.

### 4. The assets — chosen the same way, presented differently

Assets go through the same one-candidate-at-a-time run as the copy. **But the
candidates do not fit inside an option's `description`** — a sitelink is three
lines, a price row is three columns. So:

⚠️ **Print asset candidates in your message text, numbered, laid out the way
the ad renders them.** The `AskUserQuestion` options then refer to the numbers.
Instructed by the user 2026-08-09.

```
1. AIVIQ Premio Dual                    ← ENDA MED PROGRAM
   1 393 kr, billigast av tvålådorna
   Korgen 22 x 16 cm, ett lager

2. Ninja AF500EU
   2 470 watt i en enda kammare
   10,4 liter när delaren lyfts ur
```

**Revenue-bearing products come first and are marked as such.** Before offering
any product asset, resolve every product's `merchant` against
`lib/affiliate-snapshot.json` and split the list in two: the ones we have a
programme with, then the rest. On `/airfryer` that was **one product out of
ten** — every other click would cost money and could not earn any. The user
still took the winner, but that has to be a decision made in daylight.

⚠️ **The snapshot, not `.agent/adtraction-programs.json`.** That file holds the
ten Adtraction programmes and Adtraction is one network of eight; the snapshot
carries all 145 merchants we can actually link, keyed on `name` with a `domains`
array beside it. Reading the one file is the `/hygrometer` fault one level down:
reading a catalogue is not reading the catalogues.

⚠️ **A merchant with no programme anywhere is dropped from the candidate list,
not merely marked.** The split above is for a product whose programme is thin,
forbids paid search, or pays badly — there the trade is the user's. A merchant
that appears in no network at all cannot earn on any click, so a price row or
sitelink pointing at it is a cost with no possible return, and offering it as a
candidate asks the user to decide something that has no upside.

⚠️ **This covers assets that carry a URL. It does not cover headlines or
snippet values, and this paragraph did not say so until 2026-08-15.** A price
row and a sitelink send the reader to a shop; a headline and a snippet value are
text about the page. Dropping the shop from the first is arithmetic. Dropping it
from the second removes a true statement about our own comparison.

`/lamineringsmaskin` is the case, and Peter overruled the recommendation
**twice in one run**, both about Hama A4/250 at TheMobileStore — the page's only
merchant absent from all 145 snapshot merchants, the gap list and the blocked
list:

| Candidate | Recommended | Taken |
|---|---|---|
| `Bäst budget: 449 kr` (headline) | against | **yes** |
| `Hama` among four snippet values | three would do | **yes** |
| Price row at 449 kr | drop | dropped |
| Product sitelink | drop | dropped |

The page's floor **is** 449 kronor; the H1, the title and the meta description
all say so, and a headline that says it sells the comparison rather than the
shop. **Strike the merchant from `priser` and from product `sitelinks`, and
leave `rubriker` and `snippets` alone.** Say in the report which family the
product was struck from, so the asymmetry is visible rather than looking like an
oversight.

`/skrivare/laser` 2026-08-15 is the case. **Elon bore two of twelve products**,
HP Color LaserJet Pro 3202dw and HP LaserJet M110w, and is absent from the
snapshot's merchants *and* from its gap list — so it is not even a programme we
have noticed and failed to join. Both were struck before the asset questions
were written. Say in the report which products were dropped and why; that is
what turns a silent exclusion into a `fix-butiker` row.

**Sitelink titles name the product, never the award.** `AIVIQ Premio Dual`,
not `Bäst i test`. The two description lines are two USPs compressed from
`pros`. This is how a sitelink can name products without revealing which one
won — the ordering carries no verdict.

**Section sitelink titles carry the category word.** `Airfryer köpguide`, not
`Köpguide`; `Jämför tio airfryers`, not `Jämför alla tio`. A sitelink is read
without the page around it, so a bare `Köpguide` says nothing about what.

⚠️ **The FAQ anchor is where this rule gets dropped, and the fix is `FAQ om
{label}`.** `Vanliga frågor` fits in 25 characters and carries nothing;
`Skaftdammsugare, vanliga frågor` carries it and is 31, so the long form is
not available on any page with a long category word. `FAQ om skaftdammsugare`
is **22**, and `FAQ om eltandborste` is 19.

Peter wrote that title in by hand on `/skaftdammsugare` 2026-08-11 after a bare
`Vanliga frågor` had already shipped on `/eltandborste` the day before. Both
times the long form had been measured, found too long, and the rule abandoned
instead of the wording being changed. **Measure the short form before you
conclude the rule cannot be kept.**

**Offer the section sitelinks as one multi-select over every section, never as
ready-made pairs.** The page has four or five anchors worth linking, and which
of them a reader wants is not something a pair can express. On
`/vattenfontan-katt` 2026-08-10 the question offered pairs and the user wrote
in all four sections by hand; `/sous-vide` had already shown that pre-built
packages get ignored in favour of a hand-written answer. Google shows four to
six sitelinks at a time and rotates, so more candidates is more to optimise on
rather than clutter.

**Price rows use the product name as header** for the same reason, with a
compressed spec as description. Google needs at least three rows to show the
asset at all.

⚠️ **And it takes at most eight. `ads-push.ts:431` does `.slice(0, PRICE_MAX)`
with `PRICE_MAX = 8` and says nothing about what it cut.** So a page with more
ranked products than eight does not get a truncation warning — it gets a
silently shorter price asset, and the run still reports success.

**`/ekolod` 2026-08-15 shipped eleven rows and eight reached the account.** The
rows are in ascending price order, so the three dearest were dropped: 6 992,
8 900 and **9 895, which is the winner**. The push printed `30 operations` and
`1 created`, and the report to the user said eleven price rows. Nothing in the
repo contradicted it: `ANTAL.prisradMax = 8` sits in `lib/annons-schema.mjs` and
`check-annonser.mjs` never reads it, so a file with nine rows checks green.

**Where the page has more than eight ranked products, ask which eight** — never
let the ordering decide it. Ascending order puts the winner last, which is the
one row you least want to lose. On `/hundpejl` the nine became eight by striking
Lafayette GPS Mini 4G at 4 995, which sat 6 kronor from MiniFinder Rex and 4
from Tracker Luna: three rows inside ten kronor tell the reader nothing, and
cutting it kept both the 1 999 floor and the 13 900 winner.

⚠️ **A product sitelink and a price row are two separate decisions, and an answer
to one does not predict the other.** `/ekolod` 2026-08-15 is the case and it is
the strongest possible one: **every product sitelink was refused** — the first
page to ship with none at all, although all five candidates were Humminbird at
merchants paying 7,5 % — **and all eleven price rows were taken**, including the
six at a merchant we have no programme with. The two families name the same
products from the same data, and they were answered in opposite directions in the
same round.

Ask them as two questions, never as one, and do not carry the answer to the
sitelink question into the price question's recommended option. Where the page
ends up with no product sitelinks, that is a complete answer and not a gap: the
section anchors carry the ad on their own.

⚠️ **No product sitelinks is now the normal answer, so put it first in the
question.** Five runs: `/ekolod` none, `/vaderstation` none, `/soundbar` none,
`/hundpejl` three, `/lamineringsmaskin` none. **Four of five.**

`/lamineringsmaskin` is what settles it, because it removes the last
explanation. The earlier nulls could be read as a verdict on the shops — thin
programmes, merchants we could not earn on. Here **all six candidates sat at
InkClub on 15 %**, the page's best-paying programme, and the answer was still
`Inga`. The same products supplied **all eight price rows** in the same round.
It is the family being declined, not the merchants.

Write the question with `Inga — sektionerna bär annonsen` as the first option
and the precedent in its text, then the candidates below it. **Keep building and
measuring the candidates** — a question with nothing to decline is not a
question, and `/hundpejl` shows the answer can still go the other way.

⚠️ **Noll produktsitelinks är normalutfallet, och `Inga produktsitelinks` ska stå
först bland alternativen.** Fyra körningar, tre refuserade helt: `/ekolod` noll,
`/vaderstation` noll, `/soundbar` noll, `/hundpejl` tre — mot elva, åtta, åtta
och åtta prisrader. **Prisraderna togs varje gång.**

Det ändrar inte att frågan ställs, och den ska ställas fullt ut med varje
kandidat utlagd som annonsen renderar den. Det som ändras är ordningen och
tonen: ett paket lagt fram som rekommendation ber användaren att avfärda något
tre gånger av fyra, och en sida som skeppar utan produktsitelinks ska inte
rapporteras som en lucka.

`/soundbar` 2026-08-15 är den senaste, och den utesluter den billigaste
motförklaringen: **alla åtta kandidater var intäktsbärande** — Elons tre var
redan strukna — så refusionen handlade inte om att länkarna inte betalade.
Sektionsankarna svarades `alla sju` i samma runda. Läsaren ville ha sidans
avsnitt, inte dess produkter.

**Callouts are off by default.** Decided by the user 2026-08-09. `check:annonser`
allows zero; the four-minimum only applies to a page that wants them.

**Snippets: `Varumärken` when the page carries four brands or more**, otherwise
`Modeller` from `shortName ?? name`. Decided by the user 2026-08-09. Four
distinct brands means the brand list covers more searches than a model list
does, and the values are shorter so more of them fit.

⚠️ **Four is where the recommendation flips, not where the choice is made.** Put
both families in the question with their measured values, whichever side of four
the page falls. `/ekolod` 2026-08-15 carries three brands, so the rule said
`Modeller` and offered six model values against Humminbird, Garmin and Raymarine
— and Peter took `Varumärken` with three, exactly Google's minimum. A page one
brand under the line is where the question is worth most, not where it can be
skipped.

⚠️ **`Varumärken` and `Modeller` are not two ends of a choice. A page may ship
both, and one now has.** `/hundbur-bil` 2026-08-15 carries exactly four brands —
the line where the recommendation flips — so both families were laid out with
their measured values, and Peter took **both**: `Varumärken` with MIM Safe,
Thule, 4pets and Artfex, `Modeller` with nine values off `shortName`. Two
snippet assets on one ad group; Google rotates between them, so more candidates
is more to optimise on.

So the question is *which families the page should carry*, not which one wins.
Offer `Båda familjerna` as an option on every page where both clear Google's
minimum of three values — the four-brand line still decides which one is
recommended, and nothing else about it changes.

⚠️ **This rule said `Märken` until 2026-08-14, and `Märken` is not a header Google
accepts.** It sat in 21 ad files and in `SNIPPETRUBRIKER`, whose comment claimed
the list was Google's own. Corrected at source: the files, the schema and this
rule all say `Varumärken` now. `Sortiment` went the same way, unused and also not
in the vocabulary.

**The whole list we may use, from Peter 2026-08-14.** Two are in use; the rest are
here so a future page does not have to rediscover them.

| Header | |
|---|---|
| `Varumärken` | in use, four brands or more |
| `Modeller` | in use, otherwise |
| `Stilar`, `Tjänster`, `Serier`, `Typer`, `Utbildningar` | available, unused |

⚠️ **`Modeller` is not in Google's *Swedish* enum. It is in the Danish one, and
Google accepts it regardless.** Peter's call and deliberate: it is correct Swedish
and fits better than what the Swedish enum offers. **Do not "correct" it** — the
next person to check it against the Swedish list will read it as a bug. Header
validity is not partitioned by campaign language as tightly as the documentation
implies.

`checkPlan` refuses anything outside the list before a request is sent, and names
the alternatives. Left to Google it fails as `INVALID_FORMAT`, which names neither
the word nor the options and takes the whole atomic campaign down with it.

⚠️ **The header goes in Swedish and Google handles the language itself. Never send
English.** Peter's correction 2026-08-14, after an English `Brand` reached a live
ad on `tv-husvagn`. The mistake behind it is the one worth remembering: the first
validate rejected `Märken` as `INVALID_FORMAT`, that was read as "the vocabulary
is English", and `Brands` was sent instead — which **validated**, and then
published in English. A value Google accepts is not therefore the value you want,
and `validateOnly` cannot tell the difference. Only reading the published ad can.

⚠️ `shortName` sometimes already contains the brand and sometimes does not —
`Ninja AF500EU` against `SP 88`. Prefix the brand only when it is missing, or
you ship `Profsafe Profsafe S 1500`.

### 5. Check

Run `node scripts/check-annonser.mjs --sida {slug} --strict` and fix everything
it names. It must exit 0 before the page is done.

### 6. Export, diff, ask, push

⚠️ **The run is not over at step 5, and the calibration loop is not the ending
either.** Export the CSV, show `pnpm ads:diff`, then **ask the push question** —
every time, including when you expect the answer to be `Pausad`.

```bash
pnpm annonser:export                                   # always, every run
cd C:\code\redpoint && pnpm ads:diff -- --slug {slug}  # read-only
```

The full protocol — what the diff means, the three answers, the two-key status
system, how much to push — is `## 6. Push to Google Ads, over the API` below.
This step exists so the order reads through to it.

⚠️ **`Never deploys and never pushes` in `What this skill never does` means
never without the answer to that question. It has never meant do not ask.**
2026-08-15 is the case: `/skrivare/laser` was written, checked, exported and
calibrated, and the run reported as finished with the campaign never having
reached the account. Nothing recorded that, because a question that is not asked
leaves no trace anywhere — the file exists, the check is green and the report
reads complete.

The push is safe to ask about by construction. The campaign's default answer is
`PAUSED`, **the ad group is always `PAUSED` whatever the answer**, and starting
spend therefore needs two separate unpausings by a human in the Ads UI. That
design is the reason the question is a question and not a gate.

### 7. Then the calibration loop

Only now. It is at the end of this file, and it is genuinely last.

## The export format is settled — do not redesign it

`scripts/export-annonser.mjs` writes **one file per page**, `{slug}.csv`, in
Google Ads Editor's own export layout: UTF-16LE with BOM, tab separated, CRLF,
190 fixed columns from `lib/annons-kolumner.mjs`, one row per entity.

One file per page, not one for the site — decided by the user 2026-08-09. A
page can then be imported or rolled back on its own, and a mistake reaches one
campaign instead of every campaign. That matters more than it sounds: an early
export went into the wrong account and was posted before anyone noticed.

That layout is not a guess. It was read off a file that Editor exported after
a successful import, that was then re-imported and posted without error. The
first attempt — nine narrow UTF-8 files — failed six different ways, and the
mojibake reached a live account.

⚠️ **`check:annonser` does not verify any of this.** It knows everything about
what may appear in an ad and nothing about whether the file will import. If
the export breaks, it breaks silently until Editor complains.

## 6. Push to Google Ads, over the API

Since 2026-08-14 the JSON does not have to be imported by hand. redpoint holds an
approved developer token and an authorised refresh token for `199-130-2411`, and the
plan this skill writes is already shaped like an API payload.

**The CSV is still written, every time.** Peter's ruling 2026-08-14: the API never
replaces the export. Editor is a better review surface than a terminal, and it is the
fallback when the API is down or a token lapses. They are not alternatives, so never
offer the choice as "API or CSV" and never skip `pnpm annonser:export` because a push
succeeded.

### First show the diff. It is read-only

```bash
cd C:\code\redpoint
pnpm ads:diff                      # every plan
pnpm ads:diff -- --slug airfryer   # one page
```

Four GAQL queries and set arithmetic. It prints what would be created, what would
change, what already matches, and what is **blocked**. Nothing is written.

⚠️ **Read the diff before pushing, every time, including on `auto`.** It earned that
the first time it ran: three live campaigns still carried `Uppdaterad augusti 2026`
from the first import, before the decision on 2026-08-08 to capitalise the month. The
diff surfaced a real one-headline difference on each. Without it a push is a silent
overwrite of whatever a human last did in the interface.

**A blocker is never resolved automatically.** Two campaigns with the same name, an
existing campaign with more than one ad group, or a keyword in the account that is not
in the plan — each stops that page and nothing else. A keyword somebody added by hand
is somebody's work, and a push that removes it is a push that destroys it.

### Then ask, once per run

**Three answers, and `Paused` is the default:**

| Answer | What happens |
|---|---|
| **Pausad** (default) | Campaign created `PAUSED` |
| **Aktiverad** | Campaign created `ENABLED` |
| **Hoppa över** | Nothing pushed. The JSON and the CSV are still written |

⚠️ **The ad group is always `PAUSED` and the ads are always `ENABLED`.** Not asked,
not configurable, and not a contradiction to tidy up. Peter's ruling 2026-08-14, and
it is a two-key system: because the ad group is paused, **a push can never start spend
on its own** — someone has to unpause the group in the Ads UI as a separate act. Do
not enable the ad group just because the campaign was answered `Aktiverad`.

The ads are `ENABLED` inside that paused group so Google reviews them and returns an
approval status. A paused ad in a paused group tells you nothing.

The statuses live once, as constants in `redpoint/lib/track/ads-campaigns.ts`. The
`status` field in the JSON's `kampanj` block predates all of this and does **not**
drive the push; the answer to the question does.

### How much to push, and which

**A create is permanent.** A campaign can only be set to `REMOVED`, never deleted, and
its name stays reserved for good. So the run is always narrowable, and the first real
write on a new shape should be one page you can then look at in the interface.

```
/build-ads apply                    every ready plan
/build-ads apply one random          one at random, reported by name
/build-ads apply airfryer            one named page
/build-ads apply utom babyvakt,massagepistol   everything except these
/build-ads apply tre                 the first three
```

Which map onto:

```bash
pnpm ads:push -- --apply
pnpm ads:push -- --random --apply
pnpm ads:push -- --slug airfryer --apply
pnpm ads:push -- --except babyvakt,massagepistol --apply
pnpm ads:push -- --limit 3 --apply
```

⚠️ **`--random` prints the slug it picked, as a `--slug` command.** A run nobody can
repeat is a run nobody can diagnose.

⚠️ **A narrowed run says what it left out.** `--limit` and `--except` both name the
count they dropped, because a capped run that reports the full total reads as
"everything went up" when it did not.

⚠️ **Pass several slugs separated by commas *or* spaces.** PowerShell rewrites `a,b`
into `a b` before the script sees it, so both are accepted and neither is a mistake.

## 7. Syncing the other way: the account is usually right

⚠️ **Peter edits ads in the Google Ads interface, and those edits are the ones to
keep, most of the time.** Confirmed 2026-08-14, and visible in the API: `change_event`
shows him editing assets on `kaffebryggare`, `robotdammsugare` and `brandvarnare` from
`GOOGLE_ADS_WEB_CLIENT` on 2026-08-09 and 2026-08-10. A push that ignores that
destroys real work.

So the plan is **not** the source of truth for everything, and pushing blindly is
wrong in one direction exactly as pulling blindly is wrong in the other.

### Two causes, and they look identical

A field differs either because **a human refined it in the UI** — the account is right
— or because **the page was rebuilt** and a price moved — the plan is right. The values
alone cannot tell you which. That is why there is a rule per field rather than a
winner.

### Fields the plan always owns. Never asked

| Field | Why it is not a preference |
|---|---|
| Price assets | A price that moved is a factual error, not a style choice |
| Sitelink descriptions | They quote prices |
| Structured snippet values | Model names come from the page's field |
| Final URLs | Derived from the slug |

⚠️ **Do not make these askable.** `1 393 kr, billigast av tvålådorna` outliving the
price is the site's core promise breaking in an ad, and a question whose wrong answer
publishes a false price is a question that should not be asked.

Keywords are neither: an account keyword missing from the plan is reported both ways
and never removed, because a hand-added keyword is somebody's research.

### Fields where the skill asks. Default: the account

**Ask once per run, four groups, `Kontot` pre-selected:**

| Group | Options |
|---|---|
| Headlines | **Kontot** / Planen |
| Descriptions | **Kontot** / Planen |
| `path1` and `path2` | **Kontot** / Planen |
| Callouts | **Kontot** / Planen |

Peter's ruling 2026-08-14. These are craft, he tunes them by hand, and the default
respects that — but a page rebuilt around a new winner sometimes genuinely needs the
plan's copy, so the answer is a question and not a constant.

⚠️ **Provenance still guards the answer.** Before the account wins a field, confirm a
human actually changed it: `change_event` with `client_type = GOOGLE_ADS_WEB_CLIENT`
and a `user_email`, timestamped after our own last push. Without that, our own
previous push looks exactly like a hand edit, and a rolled-back push resurrects
itself. `change_event` only reaches back **30 days**; past that, provenance is
unknowable, the default applies, and the run must say it fell back rather than let it
pass silently.

⚠️ **The CSV is re-exported after a pull, always.** A pull that updates the JSON and
leaves the CSV stale has made the two disagree, which is the state this whole section
exists to prevent.

**Status 2026-08-15: `ads:diff`, `ads:push` and `ads:pull` are all built.**

```bash
cd C:\code\redpoint
pnpm ads:pull                                  # read-only, every plan
pnpm ads:pull -- --slug kaffebryggare          # one page
pnpm ads:pull -- --take rubriker --apply       # write that group into the plan
pnpm ads:pull -- --take all --apply            # every craft group
```

`--take` names the groups the account may win — `rubriker`, `beskrivningar`,
`sokvagar`, `callouts`, or `all` — and nothing is written without both it and
`--apply`. Two keys, like the push. The four groups map onto the four questions
above, so the answer to each question is a word in `--take`.

⚠️ **Derived fields are not reachable from the pull at all.** Prices, currencies,
final URLs and snippet values are not flags and not options. That is the table
above, enforced rather than documented.

### Provenance is necessary and not sufficient

⚠️ **`change_event` cannot tell our own posted export from a hand edit**, because
posting an Editor export *is* a hand edit as far as Google is concerned. The
first real pull, 2026-08-15, found one headline differing on `brandvarnare`,
`kaffebryggare` and `robotdammsugare`: `Uppdaterad Augusti 2026` in the plan
against `Uppdaterad augusti 2026` in the account. `change_event` attributes the
lowercase form to Peter, from `GOOGLE_ADS_WEB_CLIENT`, on 2026-08-09 — so the
guard passes and the account wins.

**The account is wrong.** That is the pre-decision spelling from the very first
import, and the capital month is Peter's ruling of 2026-08-08, written into
`slotplan.md` as *står orört och ska inte ändras tillbaka*. A pull with
provenance alone would have quietly reverted a decision six days after it was
made.

Two further tests now run, and both refuse to write rather than ask:

- **A case-only difference is never craft.** Same sentence, different capitals:
  that is a spelling rule, a typo, or an old import, and taking it as a
  preference reverts whichever it is. Reported as `not taken: case-only`.
- **An empty account side never wins.** Nothing in the account means *never
  pushed* far more often than *deliberately removed*. Reported as
  `not taken: structural`.

The second was measured the expensive way in the same session. `--take all
--apply` emptied the callout arrays on all three plans — five callouts each,
written before callouts were switched off by default on 2026-08-09, against
three campaigns with no callout asset attached — and the files had to be
restored from git. The flag did exactly what it said; the rule was missing.

⚠️ **`synkad` is what makes provenance work at all, and no plan carries it yet.**
Until a plan has been written by a pull, every `change_event` looks newer than
our last write and the run says so out loud: *no synkad stamp, so this cannot be
told from our own posted export.* Treat that line as *the guard is not armed on
this page*, not as noise.

⚠️ **`DURING LAST_30_DAYS` is rejected on `change_event`**, with
`START_DATE_TOO_OLD` — the preset starts at midnight thirty days back, which is
a few hours more than thirty times twenty-four, and the boundary is exact. The
script writes the range out with a day of margin. `change_event` also *requires*
a `LIMIT`; without one it is a hard 400 that names neither problem.

## 8. `/build-ads update` — three sources, not two

⚠️ **There are three places a value can live, and the two-way model above missed one.**
Two real cases on 2026-08-14 proved it, and they fail in opposite directions:

| Case | What drifted | Direction |
|---|---|---|
| `babyvakt` | The plan says 599 and 799; `lib/data` says 459 and 879 | **`lib/data` → plan** |
| `sous-vide` | Peter fixed `±` in the Ads UI, because Google rejects the character | **account → plan** |

`babyvakt` has nothing to do with the account at all. Its plan is stale against its own
page, which is what `check:annonser` rule 5 catches and what no amount of pulling from
Google would fix. So:

```
lib/data/{slug}.ts        the page's truth: prices, products, URLs
content/annonser/*.json   the plan
Google Ads                the account
```

### The three phases, in this order

**A. Refresh the derived values from `lib/data`.** Prices, currencies, final URLs,
snippet values, the price-offering amounts. These are facts about the page and the plan
does not get an opinion. Fixes `babyvakt`.

**B. Reconcile the craft text with the account.** Headlines, descriptions, `path1`,
`path2`, callouts, sitelink link text. Account wins by default, asked per group. Fixes
`sous-vide`.

**C. Push the merged result, then re-export the CSV.** Always both, never one.

⚠️ **A before B, and it is not interchangeable.** B compares text against the plan's
numbers, so running it first compares against numbers A is about to change and asks
questions that answer themselves.

### The five buckets every field falls into

| Bucket | Fields | Rule |
|---|---|---|
| **Derived** | price amounts, currency, offering `finalUrl`, ad `finalUrls`, snippet values | Comes from `lib/data`. Plan wins, never asked |
| **Craft** | headlines, descriptions, `path1`, `path2`, callouts, sitelink link text | Account wins by default, asked per group |
| **Price-bearing text** | sitelink `description1`/`description2`, price offering `header`/`description` | Account wins **only if** every price in it still matches `lib/data`. Otherwise a conflict |
| **Conflict** | anything both sides changed since the last sync | Always asked, no default offered |
| **Structural** | sitelinks, offerings or keywords added or removed | Reported, never auto-resolved |

⚠️ **A price-bearing string is the one that can carry a lie in from either side.**
`En tiondels grad för 1 099 kr` is craft *and* a price. Pull it after the price moved
and the ad quotes a figure the page does not.

⚠️ **Detect a price by the currency word beside it, never by "it is a number".**
`800 W`, `0,1 grader`, `84-147` and `1 200 W` are all numbers and none is a price. The
test is a figure adjacent to `kr`, `kronor` or `:-`. A naive numeric guard flags the
watt rating in `800 W, +7- 0,1 grader` and teaches everyone to click past the warning.

### The provenance guard, and what it is for

Before the account wins any field, `change_event` must show a human made the change:
`client_type = GOOGLE_ADS_WEB_CLIENT` with a `user_email`, after our own last write.
Otherwise our previous push looks identical to a hand edit and a rolled-back push
resurrects itself. Needs `synkad` stamped into the plan on every successful write.

`change_event` reaches back **30 days**. Past that, provenance is unknowable: the
default applies and the run says out loud that it fell back.

### Grammar

```
/build-ads update                every live page, all three phases, asks per page
/build-ads update sous-vide      one page
/build-ads update auto           no questions, defaults applied, everything logged
/build-ads update fran-data      phase A only: refresh from lib/data
/build-ads update fran-kontot    phase B only: pull the craft text
```

Phase B is `pnpm ads:pull` in redpoint; see §7 for the flags and the two guards
that refuse a write outright. Phase A has no script yet and is still done by
hand out of `lib/data`.

⚠️ **A before B, and the pull depends on it.** The price guard in the pull
compares figures in account text against the plan's own `priser[].pris`, which
is exactly what phase A refreshes. Run B first and it validates against numbers
A is about to change.

⚠️ **`update` never touches copy nobody asked about.** It reconciles the three sources.
Writing a new headline is `/build-ads` on the page, not this.

⚠️ **A pull carries a typo as faithfully as a fix.** `sous-vide` came back from the
account as `800 W, +7- 0,1 grader`, which is `+/-` typed without Shift. The account
being usually right does not make it always right, so `update` shows every pulled
string before it is written and never applies a text pull under `auto` without logging
the exact before and after.

## How the copy is chosen: one candidate per option

**Every headline and every description is chosen by the user, one candidate at
a time.** Not three bundled sets — a slot per question, four candidates per
slot, and the exact string lives in the option's `description` field.

```
Question:  Rubrik 3 av 15 — vinnaren. Vilken?
  label        Bäst i test: Ninja AF500EU
  description  "Bäst i test: Ninja AF500EU" — 26/30. Märke och
               modellbeteckning, mest exakt.
```

Five rules make this work, all learned on `/airfryer` 2026-08-09:

1. **`multiSelect: true` on every copy question.** A slot is not one headline.
   The user took three of four candidates for the category slot and four of
   four for the calls to action. Single-select throws away good lines.
2. **Every candidate carries its measured length** — `23/30`, computed with
   `rakna()` before the question is asked, never estimated.
3. **Every candidate carries the reason to pick it**, and the reason to not.
   Flag near-duplicates, claims that would be untrue, and text that ages. One
   candidate was rejected in-flight because `Fem kriterier, samma vikt` would
   have been false: the weights are 30/20/20/15/15.

   ⚠️ **Flag a near-duplicate twice at most.** Once in its own slot question and
   once if it survives to the cut. A pair the user has kept through both is a
   decision, not an oversight, and raising it a third time reads as arguing with
   an answer already given. `Bäst sous vide 2026` and `Sous vide bäst i test
   2026` both shipped on `/sous-vide` for exactly that reason.
4. **Keep going after fifteen.** Present every slot you planned even when the
   count is already met. Stopping early denies the user candidates they never
   got to rule on. Then, **if the total exceeds fifteen headlines or four
   descriptions, show the whole set and ask which to drop.**

   ⚠️ **When the fifteen are already full, put `Hoppa över sloten` first in the
   remaining questions and say in the question what a yes costs.** The
   candidates still get written and still get shown — this is about the order
   of the options, not about dropping the slot. On `/alkoholmatare` the three
   written insight slots were presented that way, skipped three out of three,
   and the cutting round never had to happen.

   This does not contradict the insight group winning three of four on
   `/laserskarare`. There the places were still open. **The two rows together
   say the outcome turns on whether there is room left**, which is exactly what
   the question has to make visible.

   ⚠️ **Ask the cut slot by slot, not as ready-made packages.** Go back through
   the groups that produced more than one survivor and ask, per group, which of
   *those* to strike. On `/sous-vide` the alternative was tried and failed:
   three pre-built trim packages were offered, the user ignored all three and
   wrote the strikes by hand, and the hand-cut removed both call-to-action
   headlines because nothing in a package view shows what a theme costs. A
   per-group question keeps the choice inside one theme at a time, where the
   trade is visible. Check the theme spread **after** the cut, not before, and
   raise it separately if a whole theme has gone to zero.

   ⚠️ **Write the target in the question, and what each option leaves.** The
   cut questions are `multiSelect`, so a user striking generously in two groups
   overshoots without seeing it. On `/vaxthus/fristaende` two needed to go, four
   were struck, the set fell to thirteen of fifteen, and filling it back cost a
   whole extra round. Say `sjutton valda, femton får plats, två ska bort` above
   the form and carry the running total into the option labels.

   ⚠️ **`[No preference]` means choose for me, not ask again.** Three theme
   questions in a row came back that way on the same page, and the follow-up
   question about one of those themes was a round nobody needed. Take the
   candidate you would have put first, say which one you took, and move on.

   ⚠️ **A slot that comes back with no answer at all is the same case.** On
   `/massagepistol` 2026-08-10 slot 12 of 14 was asked, came back unanswered,
   was asked again in the next round, and came back unanswered again — a whole
   round spent on one slot. Silence is not a different signal from
   `[No preference]`; it is the same instruction to decide. **Take the
   candidate you would have put first, name it in your message, and never ask
   about the same slot twice.**
5. **Show the exact strings with character counts in your message text too**,
   not only inside the options. These are strings that ship verbatim.

Build a candidate pool of roughly four per slot and measure the whole pool in
one pass before asking anything.

## Do not give away the answer

**The ad must not name the winner.** Decided by the user on 2026-08-09:
*"vi avslöjar för mycket"*. If the ad says which product won, the reader has
what they came for and no reason to click.

`Vinnaren kostar 1 890 kr` is fine — a price without a name is a reason to
click. `Bäst i test: Ninja AF500EU` is not.

This supersedes the old one-brand-headline allowance in practice: `/airfryer`
ships with **zero** brand names across all fifteen headlines. Rule 9 in
`check:annonser` still permits one, because zero is inside that limit and
because a page may yet have a reason to name a product.

**Sitelinks and price assets may name products** — they just may not name the
*winner as winner*. Resolved by the user 2026-08-09: title is the product name,
description is two USPs, and no row carries an award label. A reader sees the
range and can jump into any of it; nothing tells them which one placed first.

## Eligibility: ask, never decide

Check the winner's `merchant` against `.agent/adtraction-programs.json` and
`.agent/affiliate/korlista.md`. `merchant` may be a string literal or
a constant defined at the top of the data file — resolve both.

⚠️ **Grep the full network catalogue too, and treat the tracker row as a claim
rather than an answer.** `adtraction-programs.json` holds the ten programmes we
are in; `.agent/adtraction-se-katalog.json` holds the **480 we could apply to**,
and a `korlista.md` row saying *inget program funnet* was written by somebody who
may have checked the shop's footer instead of that file. One `node -e` over the
catalogue costs nothing and it is the whole difference between a page with a
paying winner and a page we skip.

`/namnlappar` 2026-08-11 is the case. The page build read `namnlappar.se`'s
footer and its front-page pixels, found neither, and filed the winner under
*butiker utan kartlagt program* with the note *vi tjänar noll på den*.
`Filur Namnlappar SE` sits in the SE catalogue at **12 % and 45 days**, paid
search allowed. Had this skill trusted the row, it would have opened the
eligibility question on a page that appeared to earn nothing on its winner and
in fact earns 28,68 kr an order. Fix the row in the same pass.

⚠️ **And one catalogue is not the catalogues. Ask Awin first, because it is the
only network where we are actually approved.** Adtraction's own SE catalogue
carries the note that we are approved for **zero** of its 480 programmes, while
the Awin publisher account Redpoint9 (2873595) has been approved since
2026-08-10. A merchant that sits in both is therefore reachable on Awin and only
theoretical on Adtraction, even at an identical rate.

```bash
TOKEN=$(jq -r '.oauth2Token' /c/code/credentials/awin/credentials.json)
curl -s -H "Authorization: Bearer $TOKEN" \
  "https://api.awin.com/publishers/2873595/programmes?relationship=notjoined"
```

21 414 programmes, 125 of them primary-region SE. Filter on the merchant name
and read `id`, `name`, `status` and `currencyCode`; the `description` usually
states the rate outright.

`/hygrometer` 2026-08-11 is the case, and the row that hid it looked complete.
`korlista.md` carried `Kjell & Company | 5 % / 30 d | 240 länkar` as an
Adtraction line with no remark, so nothing suggested a second route existed.
`Kjell&Company SE` is Awin id **8793**, 5 % in SEK, `status: Active`,
`relationship: notjoined` — the same rate on the network we can actually join,
covering **240 outgoing links, a fifth of the site**. Fix the row in the same
pass, as `/namnlappar` established.

The general form: `/namnlappar` found a false negative by reading the catalogue
instead of the shop's footer; this is the same fault one level up. **Reading a
catalogue is not reading the catalogues.**

When we have no programme with it at all, **stop and ask the user** with
`AskUserQuestion`: build anyway, skip the page, or stop the run. Never decide
alone, in either direction. Whether a campaign can pay is a fact about money,
not a rule about files.

⚠️ **A merchant's paid-search term is not part of this question, and this
paragraph named it first until 2026-08-10.** `ppcMarketing: 0` and
`policyPaidAds: notAllowed` forbid ads that link straight to the shop. Every
campaign this skill builds lands on our own page instead, so the term never
applies. Do not ask about it and do not treat it as an obstacle. Settled by
Peter, `money.md`, *Paid search permission covers direct linking only*.

⚠️ **Carry the standing precedent into the option text.** The question has been
answered the same way four times, most recently on `/vaxthus/fristaende` where
the winner sat at a merchant with `ppcMarketing: 0` and no approved programme,
and the answer was still *build normally*. A question that presents a settled
trade as an open obstacle asks the user to decide something they already have.
Put the precedent in the recommended option and let them overturn it if the
page is different; do not drop the question, because the numbers change even
when the answer does not.

⚠️ **This is about the winner's merchant only, and it is about the programme,
not about paid search.** A ranked product whose programme forbids paid search
changes nothing at all: its assets stay, and it keeps its sitelink and its
price row.

The reasoning lives once in `money.md` under *Paid search permission covers
direct linking only*. **It is settled, not an assumption** — this paragraph said
the opposite until 2026-08-10, and the hedge is what kept the wrong reading
alive for a day after Peter had corrected it.

## The calibration loop, after every page

The skill is meant to get better by being run, and the loop closes **after each
page** — not every fifth. Decided 2026-08-09, because the `/airfryer` run alone
produced four rules worth keeping and waiting four more pages would have meant
building four pages the old way.

**After each page:**

1. Append a row to `kalibrering.md`, next to this file: which candidates the user
   chose, which they rejected and why, what they changed by hand, and what
   `check:annonser` caught.
2. Read the log back and derive candidate edits to this file from it — from the
   record, never from memory of the conversation.
3. Present them as one `AskUserQuestion` with `multiSelect: true`: which edits
   to apply.
4. Apply the approved ones before starting the next page.

Look for a slot the user rewrote, a candidate that lost every time it was
offered, a check rule that fired twice, a keyword shape that never survived, a
phrasing the user corrected. **A rejection is evidence too** — `"vi avslöjar
för mycket"` came from a rejected question, not an accepted one, and it changed
the whole page.

Propose only edits the log actually supports. A change no run produced is a
change with no evidence behind it.

## What to report

Per page: the slug, which candidate set won, the keyword count and their summed
measured volume, and anything the check caught on the way. At the end of a run:
the pages built, the pages skipped and why, and every page whose measured
volume is `null` — those campaigns can be imported but have no known demand.

## What this skill never does

- **Never deploys and never pushes.** Only when explicitly asked.
- **Never edits a page.** If the ingress is weak, that is `fix-ingress`.
- **Never invents a price, a merchant or a search volume.**
- **Never writes an ad line claiming we measured anything.**
- **Never bids on a manufacturer name.**
- **Never sets a budget or a bid.** Those are set in Google Ads.
- **Never builds image assets.** That is a separate skill.
