@koda-sl/baker-cli 0.281.11-dev.0c0293389 → 0.282.0-dev.51855d0e5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -226,6 +226,11 @@ baker ads google query "SELECT campaign.name, metrics.clicks, metrics.cost_micro
226
226
  # Use a preset (saves tokens) — returns only actually-serving entities by default
227
227
  baker ads google query --preset campaign-performance --customer-id 1234567890
228
228
 
229
+ # Campaigns Google reports as limited by budget, with each one's bid target next to its
230
+ # actual cost per conversion. Since 17 Aug 2026 a budget-limited campaign delivers toward
231
+ # its target instead of holding its recent actual cost, so a target above actual raises cost.
232
+ baker ads google query --preset budget-limited --customer-id 1234567890
233
+
229
234
  # Include paused entities too (default is serving-only)
230
235
  baker ads google query --preset ad-copy-performance --customer-id 1234567890 --include-paused
231
236
 
@@ -612,6 +617,13 @@ baker ads google ads create --customer-id 1234567890 --ad-group-ref g_temp_<adgr
612
617
  baker ads google ads create --customer-id 1234567890 --ad-group-ref g_temp_<adgroup> --format demandGen \
613
618
  --headlines "Discover the app" --descriptions "Try it free today" --business-name "Acme" --final-url https://example.com \
614
619
  --image-assets g_temp_<img> --square-image-assets g_temp_<img_square> --logo-image-assets g_temp_<img_logo>
620
+ # Demand Gen VIDEO ad — the one format with long headlines (DEMAND_GEN_VIDEO_RESPONSIVE_AD)
621
+ # A create needs --ad-name, a video ref and a logo ref; the ad group under a Demand Gen campaign takes no --type
622
+ baker ads google ads create --customer-id 1234567890 --ad-group-ref g_temp_<adgroup> --format demandGenVideo \
623
+ --ad-name "IA — video prospecting" \
624
+ --headlines "Discover the app" --long-headlines "The programme is updated every term" \
625
+ --descriptions "Try it free today" --business-name "Acme" --final-url https://example.com \
626
+ --video-assets g_temp_<video> --logo-image-assets g_temp_<img_logo>
615
627
  # App campaign ad — images are fields on the ad itself, same asset-ref flow
616
628
  baker ads google ads create --customer-id 1234567890 --ad-group-ref g_temp_<adgroup> --format app \
617
629
  --headlines "Insure your dog" --descriptions "Refunds in 2 hours" --image-assets g_temp_<img1>,g_temp_<img2>
@@ -653,17 +665,21 @@ baker tag-manager draft show --chat <chat-id> gtm_temp_ab12
653
665
  baker actions draft --chat <chat-id>
654
666
  ```
655
667
 
656
- Command groups: `budgets`, `campaigns`, `ad-groups`, `keywords` (add/update/remove), `negative-keywords`, `keyword-lists`, `ads`, `assets` (create/update/attach/detach), `asset-groups` (create/update/attach/detach — Performance Max), `audiences`, `conversions` (create/update/goal), `bidding-strategies`, `labels`, `campaign-criteria` (add/update/remove), and `draft`. Amounts are in major currency units (converted to micros). Money/bids: `--amount`, `--cpc-bid`, `--target-cpa`, `--max-cpc` take major units; `--target-roas` a ratio. `--max-cpc` sets the max CPC bid ceiling for `TARGET_IMPRESSION_SHARE` / `TARGET_SPEND` / `PERCENT_CPC` and must be paired with `--bidding-strategy`. Less-common ops accept a `--file <payload.json>` (flags override file keys). Updates target a resource name or bare id as the positional argument; a target that names an op staged earlier **amends it in place**.
668
+ Command groups: `budgets`, `campaigns`, `ad-groups`, `keywords` (add/update/remove), `negative-keywords`, `keyword-lists`, `ads`, `assets` (create/update/attach/detach), `asset-groups` (create/update/attach/detach — Performance Max), `audiences`, `conversions` (create/update/goal/upload), `bidding-strategies`, `labels`, `campaign-criteria` (add/update/remove), and `draft`. Amounts are in major currency units (converted to micros). Money/bids: `--amount`, `--cpc-bid`, `--target-cpa`, `--target-cpc`, `--max-cpc` take major units; `--target-roas` a ratio. `--target-cpc` pairs with `--bidding-strategy TARGET_CPC` and is Demand Gen only. `--max-cpc` sets the max CPC bid ceiling for `TARGET_IMPRESSION_SHARE` / `TARGET_SPEND` / `PERCENT_CPC` and must be paired with `--bidding-strategy`. Less-common ops accept a `--file` payload — a path to a JSON file, or the JSON object written inline (flags override its keys). Updates target a resource name or bare id as the positional argument; a target that names an op staged earlier **amends it in place**.
657
669
 
658
670
  **A device criterion can be added, but never removed** — `campaign-criteria add` with a device criterion works, but Google then rejects `campaign-criteria remove` on it forever, including on a criterion you added yourself (and Baker refuses the removal before publish). Adjust it instead with `campaign-criteria update customers/<cid>/campaignCriteria/<campaignId>~<criterionId> --bid-modifier <n>`: `1` for no adjustment, `1.25` to bid 25% more, `0.9` to bid 10% less, `0` to stop serving on that device. Locations, languages and ad schedules remove normally.
659
671
 
672
+ **Images on a Search campaign** — `assets create` with an image publishes it into the advertiser's Google Ads asset library, but Google refuses the asset link that puts an image extension on a Search campaign (`AD_IMAGE` and every marketing-image field type, at campaign, ad group and customer level alike), so `assets attach` rejects it at stage time and names what to do instead. Staging an image therefore also stages a **Task** — "Add N images to <campaign> in Google Ads" — which applies with the rest of the chat, so the one step Google keeps inside its own interface is tracked rather than mentioned. Attach the image to a Performance Max asset group (`asset-groups attach`) and the Task withdraws itself. The one image a Search campaign does take through a link is the advertiser's logo: `BUSINESS_LOGO`, at campaign or customer level, on a verified account.
673
+
660
674
  **Primary vs secondary conversion actions** — `conversions update <id> --primary` makes an action a primary action (automated bidding optimizes toward it); `--no-primary` (or `--primary=false`) demotes it to secondary (reported only) — the spaced form `--primary false` is rejected, since a boolean flag written with a space is set to true and the word dropped. `conversions create` stages a new action as **secondary** unless its `--file` payload sets `"primaryForGoal": true`, so a new action never silently joins the bidding target of a Maximize Conversions or Target CPA campaign. The staged card shows an "Action optimization" row with the before/after role.
661
675
 
662
676
  **Account-level conversion goals** — `conversions goal --customer-id … --category PURCHASE --origin WEBSITE --biddable` sets Google's `CustomerConversionGoal` for one (category, origin) pair; `--no-biddable` (or `--biddable=false`) excludes the category from bidding while it keeps being measured. Neither flag defaults, and the spaced form `--biddable false` is rejected. A conversion drives automated bidding only when both halves are on — the action is primary AND its category's account-level goal is biddable — so this is where "my conversion is Primary but bidding ignores it" is fixed. One staged op per pair.
663
677
 
678
+ **Uploading conversions Google never received** — `conversions upload --customer-id 1234567890 --conversion-action 987654321 --file missing.csv` files conversions the account has no record of, for a period when the tracking was broken. The file is a CSV, JSON or one-object-per-line export whose header names the columns `orderId`, `conversionDateTime`, `gclid` / `gbraid` / `wbraid`, `email`, `phone`, `value`, `currency`; column names are matched loosely (`Order ID`, `order_id`, `transaction_id` are one column) and any column an upload has no use for is ignored and listed back in the hints. A single conversion can be given with `--order-id --date --gclid --email --value --currency` instead. Each conversion needs an order id, a date **with its timezone** (a bare `2026-08-01T10:00:00` is refused, since it means one instant to the person who typed it and another to everything else), and something to match on — a click id or the customer's email / phone. Reuse each conversion's **original** order id: that is what makes a re-send a correction rather than a second conversion. A value always carries its currency, because Google reads one without in the account's own. Emails and phone numbers are hashed at stage time, so nothing identifying is stored, sent in the clear, or kept afterwards. Staging checks the conversion action exists and is not removed (either refuses), and warns about conversions older than that action's click-through window — Google accepts those and attributes them to nothing. Like every other Google write it applies on publish; more than 200 conversions stage as several uploads that publish independently.
679
+
664
680
  **Retiring a conversion action** — `status` accepts `ENABLED` and `REMOVED` only. Google denies `HIDDEN` on every conversion-action write, so to stop bidding toward an action while it keeps measuring use `--no-primary`, and to stop it counting at all use `"status": "REMOVED"`. Conversion actions **Google owns** cannot be changed at all — Smart campaign call/directions actions, the in-ad lead form's submit, YouTube subscriptions and follow-on views, Floodlight and Search Ads 360 imports, store visits, codeless webpage actions. Staging any edit to one, a rename included, is refused up front; there is no manual route either, since the Google Ads interface will not change them.
665
681
 
666
- **Reviewing the draft** — `baker ads google draft list` renders everything you've staged as a grouped campaign ▸ ad group ▸ ad tree (with non-blocking completeness advisories), the CLI counterpart to the dashboard's Google Ads tab. Pass `--json` for the raw envelope. Aim for a fully built campaign — 2–4 ad groups, ≥5 keywords each, 2–4 RSAs with 8–12 headlines, ≥4 sitelinks / ≥3 callouts / ≥1 structured snippet, and ≥1 shared negative list; the advisories flag what's still thin.
682
+ **Reviewing the draft** — `baker ads google draft list` renders everything you've staged as a grouped campaign ▸ ad group ▸ ad tree (with non-blocking completeness advisories), the CLI counterpart to the dashboard's Google Ads tab. Pass `--json` for the raw envelope. Once the chat has published it reports the outcome instead — `published · N applied, M failed`, with a `✗` line naming each op that did not land and why. Aim for a fully built campaign — 2–4 ad groups, ≥5 keywords each, 2–4 RSAs with 8–12 headlines, ≥4 sitelinks / ≥3 callouts / ≥1 structured snippet, and ≥1 shared negative list; the advisories flag what's still thin.
667
683
 
668
684
  **Amending a staged op** — `draft amend <ref> (--file patch.json | --patch '<json>')` merges a JSON patch into an already-staged op's payload (objects deep-merge, `null` deletes a key, arrays/scalars replace) and re-validates it in full. Always prefer this over removing and re-staging — it preserves the op's ref (so dependents don't break) and its position in the draft. `draft show <ref>` prints the full staged payload (including warnings/annotations) so you can verify a change looks right before publish.
669
685
 
@@ -725,7 +741,7 @@ All errors include a `fix` object with `action`, `correctedCommand` (when applic
725
741
 
726
742
  ### X (Twitter) Ads (`baker ads x`)
727
743
 
728
- Read X Ads campaigns, line items, promoted tweets, creatives, audiences, and analytics. Powered by the X Ads API v12.
744
+ Read X Ads campaigns, line items, promoted tweets, creatives, audiences, and analytics — and stage changes that apply when the chat is published. Powered by the X Ads API v12.
729
745
 
730
746
  **Environment:**
731
747
  - `BAKER_X_ADS_ACCOUNT_ID` — default account ID (base36). Used when `--account-id` is not passed. If neither is set and exactly one X Ads account is connected, it's auto-selected.
@@ -743,12 +759,17 @@ Read X Ads campaigns, line items, promoted tweets, creatives, audiences, and ana
743
759
  | `media` | Media library (images / GIFs / videos) — `--media-type IMAGE\|GIF\|VIDEO` |
744
760
  | `audiences` | Custom audiences (size, targetable status) |
745
761
  | `targeting-criteria` | Targeting attached to line items |
746
- | `targeting-constants` | Lookup locations / interests / events / devices etc. — `--constant <name> --q "Madrid"` |
762
+ | `targeting-constants` | Lookup locations / interests / events / devices etc. — `--constant <name> --q "Madrid"`. `--location-type` is plural (`COUNTRIES`, `REGIONS`, `CITIES`, `METROS`, `POSTAL_CODES`); `tv_shows` needs `--locale` (take one from `tv_markets`) |
747
763
  | `active-entities` | Entities with metric activity in a time range |
748
- | `stats sync` | Synchronous analytics (≤7 days, no segmentation) |
764
+ | `stats sync` | Synchronous analytics (≤7 days, no segmentation). A preset asking for a longer window is shortened to 7 days and says so in `hints` |
749
765
  | `stats job` | Async stats end-to-end (creates + polls + downloads + decompresses). Must run in the background (the harness enforces this). Use for ranges >7 days, segmented stats, or when sync limits are hit. |
750
766
  | `stats job-create` | Low-level: create async stats job, return ID immediately |
751
767
  | `stats job-status` | Low-level: poll job status / get download URL |
768
+ | `campaigns create\|update\|pause\|resume` | **Staged write** — a new or changed campaign |
769
+ | `line-items create\|update\|pause\|resume` | **Staged write** — a new or changed ad group |
770
+ | `promoted-tweets create\|remove` | **Staged write** — promote posts, or stop promoting them |
771
+ | `targeting-criteria create\|remove` | **Staged write** — targeting on an ad group |
772
+ | `draft` | Review the staged Campaign ▸ Ad group ▸ Ad tree; `show`, `amend`, `remove`, `clear` |
752
773
 
753
774
  **Examples:**
754
775
 
@@ -761,9 +782,10 @@ baker ads x promoted-tweets --account-id 18ce53xyz
761
782
  # Sync analytics with a preset (saves tokens)
762
783
  baker ads x stats sync --preset campaign-engagement-7d --entity-ids abc,def
763
784
 
764
- # Free-form sync stats
785
+ # Free-form sync stats. At DAY granularity X requires both times to be midnight in the ad
786
+ # account's own timezone (`baker ads x accounts` reports it) — below, midnight in a UTC-7 account.
765
787
  baker ads x stats sync --account-id 18ce53xyz --entity LINE_ITEM \
766
- --entity-ids abc,def --start-time 2026-05-01T00:00:00Z --end-time 2026-05-07T00:00:00Z \
788
+ --entity-ids abc,def --start-time 2026-05-01T07:00:00Z --end-time 2026-05-07T07:00:00Z \
767
789
  --metric-groups ENGAGEMENT,BILLING --granularity DAY
768
790
 
769
791
  # Async job, sync from the CLI's perspective (creates → polls → downloads → returns).
@@ -778,14 +800,25 @@ baker ads x stats job-create --account-id 18ce53xyz --entity CAMPAIGN \
778
800
  --metric-groups ENGAGEMENT,BILLING --segmentation-type LOCATIONS
779
801
  baker ads x stats job-status --account-id 18ce53xyz --job-id <jobId>
780
802
 
803
+ # Staged writes — nothing reaches X until the chat is published
804
+ baker ads x campaigns create --name "Spring" --funding-instrument-id vtbot --daily-budget 5
805
+ baker ads x line-items create --campaign-id x_temp_abc --name "Spain" --objective WEBSITE_CLICKS
806
+ baker ads x targeting-criteria create --line-item-id x_temp_def --type LOCATION --value ecdce75d48b13b64
807
+ baker ads x promoted-tweets create --line-item-id x_temp_def --tweet-ids 2042198488033661285
808
+ baker ads x draft # review; after publishing, the authoritative record
809
+ baker ads x campaigns pause oilf0,omyq3 # every id in one call
810
+
781
811
  # List sync presets
782
812
  baker ads x stats sync --list-presets
783
813
 
784
- # Targeting lookups
785
- baker ads x targeting-constants --constant locations --q "Madrid" --country-code ES
814
+ # Targeting lookups (--location-type is plural; tv_shows needs a --locale from tv_markets)
815
+ baker ads x targeting-constants --constant locations --q "Madrid" --country-code ES --location-type CITIES
786
816
  baker ads x targeting-constants --constant interests
817
+ baker ads x targeting-constants --constant tv_shows --locale en-US
787
818
  ```
788
819
 
820
+ **Writes are staged.** Every `create`/`update`/`pause`/`resume`/`remove` appends an op to the current chat's draft (`BAKER_CHAT_ID`); X is not called. The draft applies when the chat is published and discarding the chat reverses it. Creates default to `PAUSED`. Budgets and bids are given in account currency (`--daily-budget 12.50`). Chain creates with the `x_temp_*` ref a create returns.
821
+
789
822
  **Caching:** account list 1h · campaigns/line items/promoted tweets 1h (date-keyed) · cards/media/audiences 6h · stats sync 1h · targeting constants 7 days. Pass `--no-cache` to bypass.
790
823
 
791
824
  **Rate limits:** server-side buckets (`xAds:read`, `xAds:write`, `xAds:analyticsSync`, `xAds:analyticsAsync`, `xAds:audiences`) sit well under X's published quotas; 429s honor `x-account-rate-limit-reset` / `x-rate-limit-reset` headers.
@@ -1063,6 +1096,8 @@ audit --format md # deliverable-ready markdown table
1063
1096
  campaign-groups create|update|pause|resume|duplicate # staged writes (see below)
1064
1097
  campaigns create|update|pause|resume|archive|duplicate
1065
1098
  campaigns url-params <ad-set-id> # UTMs for every ad in the ad set
1099
+ account url-params # UTMs for every ad in the whole account
1100
+ url-params [--ad-sets] # read what is tagged now, and what lands twice
1066
1101
  creatives create|update|pause|resume|duplicate
1067
1102
  audiences create|upload
1068
1103
  conversions create|update
@@ -1090,6 +1125,10 @@ baker ads linkedin campaigns url-params 123456 \
1090
1125
  --param "utm_source=linkedin&utm_medium=paid-social" \
1091
1126
  --dynamic utm_campaign=CAMPAIGN_NAME --dynamic utm_content=CREATIVE_ID
1092
1127
  baker ads linkedin campaigns url-params 123456 --clear # remove them all
1128
+
1129
+ # what never varies across the account goes one level up — LinkedIn appends BOTH,
1130
+ # so a key set at the account AND the ad set lands in the URL twice
1131
+ baker ads linkedin account url-params --param "utm_source=linkedin&utm_medium=paid_social"
1093
1132
  # a boolean flag written with a space (`--clear false`) is rejected: citty sets it to TRUE and drops
1094
1133
  # the word. Attach it — `--clear=false` — or leave the flag off.
1095
1134
 
@@ -1217,6 +1256,15 @@ baker analytics tracking # do the ad URLs carry the campaign, a
1217
1256
  baker analytics tracking --platform meta # the same for one platform, read from their own ad account
1218
1257
  baker analytics map --platform google --set kw=keyword # name a parameter, seen or not yet
1219
1258
  baker analytics map --platform google --remove kw # take that answer back
1259
+ baker analytics conversions # what counts, what each produced, and which Forms count nothing
1260
+ baker analytics conversions --candidates # everything these pages do, and which of it is counted
1261
+ baker analytics conversions --flow contact # one Form's endings as event keys, traffic or not
1262
+ baker analytics conversions --event page:request_demo --name "Demo requested"
1263
+ baker analytics conversions --event page:a --event submit:b --name "Lead" # both, as one number
1264
+ baker analytics conversions --event page:a --count-mode every_time # re-mode, keeping its name
1265
+ baker analytics conversions --rename "Lead form" --to "Lead" # merge two into one row
1266
+ baker analytics conversions --remove page:request_demo
1267
+ baker analytics conversions --remove-name "Lead"
1220
1268
  baker analytics delivery --page 2 # the next page of a long list
1221
1269
  baker analytics submissions --flow contact # every attempt at a Form, delivered or not
1222
1270
  ```
@@ -1229,7 +1277,13 @@ Shared flags: `--days <n>` (default 30) or `--start-date` / `--end-date` (`YYYY-
1229
1277
  - The `sparkline` on `landings` is dropped unless `--full`, and resampled to 30 points when present. It is bucketed by `--granularity` upstream, so ninety days at `hour` is 2,160 numbers per page.
1230
1278
  - Past ~25 rows the response carries a hint naming the row count and pointing at `--output md`.
1231
1279
 
1232
- **Paging the list reports.** `people`, `submissions` and `delivery` are lists of individual things rather than breakdowns — `delivery` and `submissions` are the ones the CLI exposes, `people` backs the dashboard's People screen over the same wire — and only they take `--page <n>` (1-based) and `--page-size <n>` (up to 200, default 50). The response carries `pageInfo` with `hasMore` — when it is true there **are** more rows, so a total must never be reported from one page. Pass neither flag and a list comes back at the size it always did.
1280
+ **Paging the list reports.** `people`, `submissions` and `delivery` are lists of individual things rather than breakdowns — `delivery` and `submissions` are the ones the CLI exposes, `people` backs the People section of the dashboard's landing Analytics screen over the same wire — and only they take `--page <n>` (1-based) and `--page-size <n>` (up to 200, default 50). The response carries `pageInfo` with `hasMore` — when it is true there **are** more rows, so a total must never be reported from one page. Pass neither flag and a list comes back at the size it always did.
1281
+
1282
+ **A conversion is a company-level named event, not a property of a Form.** `baker analytics conversions` is the only place "what counts" is decided, and an empty `definitions` list means every conversion number in every other report is zero — not because nobody converted, but because nothing is named as an outcome and Baker never guesses which event is the point of a page. `--candidates` lists every event these pages actually produced — each Form step and trigger, every `data-baker-*` event, every outbound destination — with volume and whether it is already counted; that list is where an event key comes from, so never invent one. `--event <key> --name "Booked a call"` starts counting it **immediately and retroactively**: the whole stored history is rescored, so naming the right event today also fixes last month. Nothing here is staged and publishing is not involved. Two events given the same `--name` become one row and one number, which is how a call booked on three different Forms reads as one outcome. `--count-mode every_time` is for the outcomes people genuinely repeat (a guide downloaded twice is two downloads); the default counts once per visit.
1283
+
1284
+ **The name is the grouping, so the CLI defends it.** Repeat `--event` to count several events under one `--name` in a single call; `--rename "<old>" --to "<new>"` renames every event under an outcome, and renaming *onto* a name that already exists **merges** them into one row and one number — the fix for a company whose Forms each invented their own word for the same thing. `--remove-name` drops an outcome entirely, `--remove` a single event key. A name that differs from one already counted only by case, accent, punctuation or a plural is **refused**: Baker groups by exact name, so "Booked a Call" beside "Booked a call" is a number split in half with both halves plausible. Every answer leads with the names already in use, and a company that has named nothing gets a standard set to start from — after which the rule is reuse, in that company's own language.
1285
+
1286
+ **`--flow <slug>` reads it from the Form's side**, listing that Form's endings already spelled as event keys with the name each is counted under, built from the Form's own file so it works before a single visitor arrives. With no `--flow`, the answer carries `flows[]` — every Form in the workspace with the step that counts for it, or nothing — which exists nowhere else: on every other report a Form that counts nothing looks exactly like a Form nobody converts on. The list of definitions is read from the store rather than from the reporting mirror, so an outcome named a second ago is in the reply that named it.
1233
1287
 
1234
1288
  **Campaign parameters are per platform, and so are the numbers.** `baker analytics ads` returns `adPlatformTraffic` (visits, conversions and *tagged* visits for each of the nine platforms), `adParams` for the platform in `--platform` only, and `adParamsUnattributed` for names arriving on visits with no click id and no recognisable source. Read `adPlatformTraffic` first: a platform with visits and zero tagged visits has untagged ad URLs, and no mapping can fix that from Baker's side. `baker analytics map` needs `--platform` for any change, accepts a parameter name **nobody has sent yet** — which is how a tracking template is configured before its campaign runs — and takes an answer back with `--remove`. `--set <name>=ignore` says a parameter is not campaign information at all, so Baker stops storing it and stops listing it.
1235
1289
 
@@ -1759,6 +1813,54 @@ baker research intent "buy running shoes,best running shoes 2026,how to tie shoe
1759
1813
 
1760
1814
  ---
1761
1815
 
1816
+ ### `baker research keyword-metrics "kw1,kw2,kw3"`
1817
+
1818
+ Search volume, CPC, top-of-page bids and competition for a list of keywords you name — no domain involved. This is the command for sizing a market you already have keywords for; `keywords-for-site` and `keyword-gap` return volume only for the terms a domain happens to rank for.
1819
+
1820
+ ```bash
1821
+ baker research keyword-metrics "chatbot whatsapp,crm whatsapp" --location mx --language es
1822
+ baker research keyword-metrics "chatbot whatsapp" --location br --language pt --clickstream
1823
+ ```
1824
+
1825
+ **Response:**
1826
+
1827
+ ```json
1828
+ {
1829
+ "ok": true,
1830
+ "data": [
1831
+ {
1832
+ "source": "keyword_planner_estimate_via_dataforseo",
1833
+ "keyword": "chatbot whatsapp",
1834
+ "planner_search_volume": 1000,
1835
+ "planner_cpc": 6.27,
1836
+ "planner_low_top_of_page_bid": 1.57,
1837
+ "planner_high_top_of_page_bid": 5.32,
1838
+ "planner_competition": "MEDIUM",
1839
+ "planner_competition_index": 66
1840
+ }
1841
+ ]
1842
+ }
1843
+ ```
1844
+
1845
+ Every keyword you ask for comes back, in the order asked. A row whose metrics are all `null` means DataForSEO holds no data on that term — not that nobody searches it.
1846
+
1847
+ **The volume is Google's own number.** DataForSEO resells the Keyword Planner figure, so `planner_search_volume` is the same value `baker ads google keywords metrics` returns; the two agreeing is not corroboration. `--clickstream` adds `clickstream_search_volume`, DataForSEO's own panel-derived estimate, which is independent — and costs twice as much.
1848
+
1849
+ **Flags:**
1850
+
1851
+ | Flag | Description |
1852
+ |----------------|--------------------------------------|
1853
+ | `--location` | Country code (us, mx, br...) or DataForSEO location code. **Defaults to us** |
1854
+ | `--language` | Language code or name. **Defaults to en** |
1855
+ | `--clickstream`| Add DataForSEO's own volume estimate (costs 2x) |
1856
+ | `--full` | Add keyword difficulty, search intent and the 12-month trend |
1857
+ | `--no-cache` | Skip cache (7d TTL) |
1858
+ | `--output` | Format: json\|csv\|md\|jsonl |
1859
+
1860
+ Max 700 keywords per call, 80 characters each.
1861
+
1862
+ ---
1863
+
1762
1864
  ### `baker research keywords-for-site "domain.com"`
1763
1865
 
1764
1866
  Get keywords a competitor targets. Use `--type paid` to see only paid keywords, `--type organic` for organic only.
@@ -1977,7 +2079,7 @@ Each external source is its own subcommand. Pick the verb that matches the sourc
1977
2079
  |---|---|---|
1978
2080
  | `baker images library <q>` | Hybrid library search (replaces `search`) | n/a |
1979
2081
  | `baker images find <q>` | Fanout: library + opted-in providers | off |
1980
- | `baker images stock <q> [--type photo\|vector\|psd]` | Magnific (Freepik's dev API)photos, vectors, illustrations, PSDs (~250M assets) | off |
2082
+ | `baker images stock <q> [--type photo\|illustration\|vector]` | Free stock across two libraries Pexels photographs, Pixabay illustrations and vectors | off |
1981
2083
  | `baker images google <q>` | Google Images via the official Custom Search JSON API | off |
1982
2084
  | `baker images pinterest <q>` | Pinterest reference imagery via ScrapeCreators (photo-real mood boards) | off |
1983
2085
  | `baker images logo <domain>` | Brand logo via Brandfetch CDN | **on** (top 1) |
@@ -2002,7 +2104,7 @@ Each external source is its own subcommand. Pick the verb that matches the sourc
2002
2104
 
2003
2105
  **Auto-ingest** runs the full `processImage` pipeline (Gemini describe + Voyage multimodal embed + OpenRouter text embed) on every hit. Override with `--auto-ingest N` (turn on) or `--no-auto-ingest` (turn off where default is on). When auto-ingest succeeds, the matching returned hit uses the Baker-owned URL and keeps the original provider URL as `sourceUrl`. After auto-ingest the next `baker images library` query for the same concept hits the local row.
2004
2106
 
2005
- **Provider context biases the description.** When a hit carries human-readable text (Giphy `alt_text` / `title`, Magnific `title`, Brandfetch type+theme, etc.) it's surfaced on the hit as `descriptionContext` and forwarded to Gemini during auto-ingest as a hint — Gemini still trusts the pixels (e.g. it'll correct a wrong botanical name) but uses the hint for cultural/brand/scene awareness it can't infer from bytes alone.
2107
+ **Provider context biases the description.** When a hit carries human-readable text (Giphy `alt_text` / `title`, Pexels `alt`, Pixabay `tags`, Brandfetch type+theme, etc.) it's surfaced on the hit as `descriptionContext` and forwarded to Gemini during auto-ingest as a hint — Gemini still trusts the pixels (e.g. it'll correct a wrong botanical name) but uses the hint for cultural/brand/scene awareness it can't infer from bytes alone.
2006
2108
 
2007
2109
  **`--context` is available on every command that adds an image to the library** — `ingest`, `use`, `upload`, `find`, `extract`, `screenshot`, `logo`, `stock`, `google`, `icon`, `gif`, `sticker`. On auto-ingest paths it **overrides** the provider-derived `descriptionContext` for every hit that gets ingested. Especially valuable for brand-vs-competitor disambiguation — Gemini cannot tell from pixels alone whether an icon belongs to "Stripe (integration partner)" vs "Stripe (competitor)". Concrete cases:
2008
2110
 
@@ -2017,6 +2119,8 @@ baker images screenshot https://competitor.com/pricing --context "Competitor pri
2017
2119
 
2018
2120
  **`baker images search` is a deprecated alias** for `library` (stderr deprecation log, removed in next minor).
2019
2121
 
2122
+ **Migrating from Magnific:** `baker images stock` used to run on Magnific, Freepik's developer API. That provider is gone and the command now searches Pexels and Pixabay together. Consequences: it is free (no `image-magnific-search` credit line any more); `--type` survives as `photo|illustration|vector` (PSDs and mockups are the one thing genuinely lost); `--license`, `--ai`, `--people` and `--order` now fail with a fix naming the replacement rather than being silently dropped; and `--orientation` loses `panoramic` while `--size` and `--locale` are new. Library rows already ingested from Magnific keep `source: "magnific"`, stay searchable, and remain a valid `--source` filter; nothing writes that value any more.
2123
+
2020
2124
  **Migrating from Tenor:** the old `baker images meme` command and Tenor provider have been removed. Use `baker images gif` (Giphy) instead. Existing library rows ingested via Tenor remain stored with `source: "tenor"` but are no longer returned by `baker images find` and `--source tenor` is no longer a valid filter on `library`. Re-ingest via `baker images gif --auto-ingest N` if you need fresh equivalents.
2021
2125
 
2022
2126
  ### `baker images library <query>`
@@ -2026,7 +2130,7 @@ Search the company library (hybrid BM25 + vector + Cohere rerank). Use this FIRS
2026
2130
  ```bash
2027
2131
  baker images library "hero banner"
2028
2132
  baker images library "logo" --aspect-ratio 1:1 --tags logo
2029
- baker images library "office" --source magnific
2133
+ baker images library "office" --source pexels
2030
2134
  baker images library "pricing" --external-url-host competitor.com
2031
2135
  ```
2032
2136
 
@@ -2038,7 +2142,7 @@ baker images library "pricing" --external-url-host competitor.com
2038
2142
  | `--min-score` | Minimum relevance score, 0-1 |
2039
2143
  | `--aspect-ratio` | Filter by aspect ratio |
2040
2144
  | `--tags` | Comma-separated tag names |
2041
- | `--source` | Filter by source (uploaded, magnific, brandfetch, google_images, firecrawl, screenshotone, iconify, giphy, …) |
2145
+ | `--source` | Filter by source (uploaded, pexels, pixabay, brandfetch, google_images, firecrawl, screenshotone, iconify, giphy, …). `magnific` still filters the rows the retired stock provider left behind |
2042
2146
  | `--external-url-host` | Filter by host substring of `externalUrl` (e.g. competitor.com) |
2043
2147
  | `--output` | Output format: `json` \| `files` \| `md` |
2044
2148
  | `--fields` | Comma-separated field names to include |
@@ -2051,12 +2155,12 @@ baker images library "pricing" --external-url-host competitor.com
2051
2155
  Fanout image search: library first, then opted-in providers in parallel.
2052
2156
 
2053
2157
  ```bash
2054
- baker images find "office" --sources library,magnific --limit 20
2055
- baker images find "office" --sources library,magnific --fallback --threshold 0.4
2158
+ baker images find "office" --sources library,pexels,pixabay --limit 20
2159
+ baker images find "office" --sources library,pexels --fallback --threshold 0.4
2056
2160
  baker images find "celebration" --sources library,giphy --auto-ingest 3
2057
2161
  ```
2058
2162
 
2059
- Providers: `library`, `magnific`, `google`, `iconify`, `giphy`, `pinterest`. Brandfetch is not part of the fanout — it takes a domain, not a query, so it lives at `baker images logo <domain>`. (`pinterest` is photo-real/candid imagery — best for sourcing fresh people and sets, e.g. a recast creator or a pinned location in a video reproduction.) Response shape: `{ groups: { library, external }, ingested, meta: { counts, errors } }`. When `--auto-ingest` is used, `ingested[]` preserves successful external ingest order and matching external hits are enriched with the Baker-owned URL plus source provenance. Partial failures (one provider throws) return the successful providers plus a `meta.errors` array; the whole call never fails on a single provider error.
2163
+ Providers: `library`, `pexels`, `pixabay`, `google`, `iconify`, `giphy`, `pinterest`. Brandfetch is not part of the fanout — it takes a domain, not a query, so it lives at `baker images logo <domain>`. (`pinterest` is photo-real/candid imagery — best for sourcing fresh people and sets, e.g. a recast creator or a pinned location in a video reproduction.) Response shape: `{ groups: { library, external }, ingested, meta: { counts, errors } }`. When `--auto-ingest` is used, `ingested[]` preserves successful external ingest order and matching external hits are enriched with the Baker-owned URL plus source provenance. Partial failures (one provider throws) return the successful providers plus a `meta.errors` array; the whole call never fails on a single provider error.
2060
2164
 
2061
2165
  **Flags:**
2062
2166
 
@@ -2071,17 +2175,28 @@ Providers: `library`, `magnific`, `google`, `iconify`, `giphy`, `pinterest`. Bra
2071
2175
 
2072
2176
  ### `baker images stock <query>`
2073
2177
 
2074
- Stock search via **Magnific** Freepik's developer API (post-acquisition rebrand `api.magnific.com`, header `x-magnific-api-key`). One library, ~250M assets covering photos, vectors, illustrations, icon sets, mockups, PSDs.
2178
+ `stock` is **one verb over the free-stock class**, not one provider. It searches **Pexels** (`api.pexels.com`, raw key in `Authorization`, `PEXELS_API_KEY`) for photographs and **Pixabay** (`pixabay.com/api`, `key` query param, `PIXABAY_API_KEY`) for photographs plus illustrations and vector artwork, then interleaves and dedupes the two. Both APIs are free — the command costs nothing and reports no credit consumption.
2179
+
2180
+ They are one verb because they carry the same licence shape: everything either holds is free for commercial use, including a client's landing page and paid social, with no attribution required. They differ only in what they hold, and an agent should not have to know which library a picture lives in.
2075
2181
 
2076
2182
  ```bash
2077
2183
  baker images stock "minimalist office"
2078
- baker images stock "flat office workers" --type vector
2079
- baker images stock "hero photo of a kitchen" --type photo --orientation landscape --ai exclude
2080
- baker images stock "brand pattern" --color "#0a0a0a" --license freemium --auto-ingest 2
2081
- baker images stock "office reaction" --people only
2184
+ baker images stock "hero photo of a kitchen" --orientation landscape --size large
2185
+ baker images stock "flat office workers" --type illustration
2186
+ baker images stock "leaf outline mark" --type vector
2187
+ baker images stock "brand pattern" --color "#0a0a0a" --auto-ingest 2
2188
+ baker images stock "oficina luminosa" --locale es-ES
2082
2189
  ```
2083
2190
 
2084
- Free tier exists but watermarks previewspass `--license freemium` to filter to clean free assets explicitly.
2191
+ **A filter only one library understands narrows the search to that library** rather than being dropped `convex/imageProviders/stock/plan.ts` resolves this once. `--type illustration|vector` and a *named* `--color` are Pixabay-only; `--orientation square` and a *hex* `--color` are Pexels-only. Querying a library without a filter it cannot honour would return an unfiltered result against a filtered request, which reads as a right answer.
2192
+
2193
+ **A partial result says so.** If one library fails the other still answers, and the response carries `errors[]` plus a `hints` entry naming the library that dropped out — so a thin result reads as unproven rather than as "stock does not have this".
2194
+
2195
+ Credits ride on each hit's `providerMeta`: Pexels `photographer` / `photographerUrl`, Pixabay `contributor`. Neither licence requires attribution, but both ask that the source be shown where results are displayed, and `externalUrl` is that link.
2196
+
2197
+ **Pixabay's tier decides what you actually get.** The full-resolution raster (`imageURL`) and the source SVG (`vectorURL`) need "full API access", requested free from Pixabay. Without it every Pixabay hit is the 1280px `largeImageURL`, so `--type vector` returns a raster of vector artwork rather than an SVG, and `--size` returns Pexels alone — the floor is applied to the file you receive, not to the original the library holds, because a 1.1MP download satisfying a 24MP request is how an agent ends up putting a soft image in a full-bleed hero. `width`/`height` on a hit describe the delivered file for the same reason. Granting full access fixes all three with no code change.
2198
+
2199
+ Pixabay's terms also require results to be cached 24h — the provider's `DAY` action cache is what satisfies that, so don't shorten it.
2085
2200
 
2086
2201
  An empty result comes back with `hints` naming the filters that narrowed the search plus the fallbacks (`baker images find --sources …`, `baker studio generate`); a provider failure returns an `error.fix` pointing at the same exits. `baker images google` does the same, minus the cross-provider fallback (it is the last-resort provider).
2087
2202
 
@@ -2089,14 +2204,12 @@ An empty result comes back with `hints` naming the filters that narrowed the sea
2089
2204
 
2090
2205
  | Flag | Description |
2091
2206
  |------------------|--------------------------------------------------------------------------------------------|
2092
- | `--type` | Content type: `photo \| vector \| psd` |
2093
- | `--orientation` | `landscape \| portrait \| square \| panoramic` |
2094
- | `--license` | `freemium` (free with attribution) or `premium` (paid, watermark-free) |
2095
- | `--color` | Hex color filter (`#0a0a0a` or `0a0a0a`) |
2096
- | `--ai` | AI-generated filter: `exclude` or `only` (default: no filter) |
2097
- | `--people` | People-in-image filter: `include` \| `exclude` \| `only` |
2098
- | `--order` | `relevance` (default) or `recent` |
2099
- | `--limit` | Max results (1–50, default 10) |
2207
+ | `--type` | `photo` (default, both libraries) \| `illustration` \| `vector` (Pixabay only) |
2208
+ | `--orientation` | `landscape \| portrait \| square` (`square` is Pexels only) |
2209
+ | `--size` | Minimum resolution: `large` (24MP) \| `medium` (12MP) \| `small` (4MP) |
2210
+ | `--color` | Colour name (`red`, `turquoise`, `black`, …; both libraries) or hex (`#0a0a0a`; Pexels only) |
2211
+ | `--locale` | Locale of the search terms, e.g. `es-ES`, `de-DE` Pexels matches translated tags |
2212
+ | `--limit` | Max results (1–80, default 10) |
2100
2213
  | `--page` | Page number for pagination |
2101
2214
  | `--auto-ingest` | Ingest top N hits (0–20, default 0) and return Baker-owned URLs on ingested hits |
2102
2215
  | `--context` | Free-text hint passed to Gemini describe to bias the generated description and tags (overrides provider-derived context on auto-ingest paths) |
@@ -2326,11 +2439,11 @@ If the target page is unreachable or returns a non-2xx status (e.g. a 404 path o
2326
2439
  Download a remote URL and store it in the library. Byte-exact deduped via sha256 + externalId, then queued for describe + embed.
2327
2440
 
2328
2441
  ```bash
2329
- baker images ingest https://img.freepik.com/free-photo/xyz.jpg --source magnific --external-id 12345
2442
+ baker images ingest https://images.pexels.com/photos/13219418/pexels-photo-13219418.jpeg --source pexels --external-id 13219418
2330
2443
  baker images ingest https://acme.com/hero.png --source firecrawl --external-url https://acme.com/pricing --context "competitor pricing hero"
2331
2444
  ```
2332
2445
 
2333
- `--source` is a closed set, validated before the request so a wrong value costs nothing: `uploaded`, `website`, `google_testimonial`, `trustpilot_testimonial`, `instagram`, `magnific`, `brandfetch`, `google_images`, `firecrawl`, `screenshotone`, `iconify`, `giphy`, `pinterest`, `ai_generated`, `layer_edit`. Use `website` for an asset pulled off a company's own site.
2446
+ `--source` is a closed set, validated before the request so a wrong value costs nothing: `uploaded`, `website`, `google_testimonial`, `trustpilot_testimonial`, `instagram`, `pexels`, `pixabay`, `magnific` (retired provider, kept for existing rows), `brandfetch`, `google_images`, `firecrawl`, `screenshotone`, `iconify`, `giphy`, `pinterest`, `ai_generated`, `layer_edit`. Use `website` for an asset pulled off a company's own site.
2334
2447
 
2335
2448
  Returns `{ imageId, deduped, contentHash }`. When `deduped: true`, an existing library row is returned — no new bytes are stored. Max ingest size 25MB.
2336
2449
 
@@ -2338,7 +2451,7 @@ Returns `{ imageId, deduped, contentHash }`. When `deduped: true`, an existing l
2338
2451
 
2339
2452
  | Flag | Description |
2340
2453
  |------------------|--------------------------------------------------------------------------------------------|
2341
- | `--source` | **Required.** Source enum (`uploaded`, `magnific`, `brandfetch`, `google_images`, `firecrawl`, `screenshotone`, `iconify`, `giphy`, `instagram`, …) |
2454
+ | `--source` | **Required.** Source enum (`uploaded`, `pexels`, `pixabay`, `brandfetch`, `google_images`, `firecrawl`, `screenshotone`, `iconify`, `giphy`, `instagram`, …) |
2342
2455
  | `--external-id` | Provider asset id — enables per-source dedup before bytes are fetched |
2343
2456
  | `--external-url` | Canonical page URL (parent page for scraped/Google hits, provider page for stock) |
2344
2457
  | `--context` | Free-text hint passed to Gemini describe to bias the generated description and tags |
@@ -2402,7 +2515,7 @@ Local-mode supported extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg
2402
2515
 
2403
2516
  | Flag | Mode | Description |
2404
2517
  |------------------|--------|--------------------------------------------------------------------------------------------|
2405
- | `--source` | both | Source enum (default `uploaded`). For remote URLs prefer `firecrawl`, `magnific`, etc. to match the origin |
2518
+ | `--source` | both | Source enum (default `uploaded`). For remote URLs prefer `firecrawl`, `pexels`, etc. to match the origin |
2406
2519
  | `--content-type` | local | MIME type override (default: inferred from file extension). Ignored for URLs |
2407
2520
  | `--external-id` | remote | Provider asset id — enables per-source dedup before bytes are fetched |
2408
2521
  | `--external-url` | remote | Canonical page URL (parent page for scraped/Google hits, provider page for stock) |
@@ -2729,6 +2842,15 @@ When `list` or `search` returns nothing, the envelope carries `meta.empty` expla
2729
2842
 
2730
2843
  If the source check itself fails, `meta.empty` is omitted entirely and the hint says so — an unverified absence is never reported as one.
2731
2844
 
2845
+ `list` is paged. `--limit` is the page size (default 50); when the library continues past the page you got, the envelope carries `meta.next_cursor` and a hint, and `meta.empty` is omitted — a page that is not the last page establishes nothing about the corpus. Read on with the same filters plus `--cursor`:
2846
+
2847
+ ```bash
2848
+ baker testimonials list --sentiment positive --limit 50
2849
+ baker testimonials list --sentiment positive --limit 50 --cursor <meta.next_cursor>
2850
+ ```
2851
+
2852
+ No `meta.next_cursor` means the library ended there.
2853
+
2732
2854
  ### `baker testimonials tags`
2733
2855
 
2734
2856
  List the available testimonial tag names — built-in defaults plus the company's custom tags. Use it before filtering with `--tags`. Defaults to a markdown list (`--output json` for the `{ ok, data }` envelope).
@@ -2779,7 +2901,7 @@ baker avatars create --name "Marta" --subject "…" \
2779
2901
  --source-image "<imageId1>,<imageId2>" --likeness-confirmed
2780
2902
  ```
2781
2903
 
2782
- Optional: `--handle`, `--speech`, `--motion`, `--setting`, `--voice-id`, `--voice-description`. Returns `{ avatarId, handle, likeness, status }`.
2904
+ Optional: `--handle`, `--speech`, `--motion`, `--setting`, `--accent`. Returns `{ avatarId, handle, likeness, status }`.
2783
2905
 
2784
2906
  #### `baker avatars update <handle>`
2785
2907
 
@@ -2788,7 +2910,7 @@ Edit an avatar. Profile flags are merged over the stored profile, so `--persona`
2788
2910
  ```bash
2789
2911
  baker avatars update marta --wardrobe "charcoal blazer over a white tee"
2790
2912
  baker avatars update marta --subject "woman in her early 50s, silver bob, …" --regenerate-sheet
2791
- baker avatars update marta --new-handle marta-founder --voice-id <voiceId>
2913
+ baker avatars update marta --new-handle marta-founder --accent "Spanish from Spain, Madrid accent"
2792
2914
  ```
2793
2915
 
2794
2916
  #### `baker avatars delete <handle>`
@@ -3004,7 +3126,7 @@ baker scheduled-actions create --template baker-ads --name "Weekly ad review" --
3004
3126
  Rules:
3005
3127
 
3006
3128
  - `--cron` and `--run-at` are mutually exclusive.
3007
- - `--run-at` must be an ISO UTC timestamp ending in `Z`.
3129
+ - `--run-at` must be an ISO UTC timestamp ending in `Z`, and still in the future — a time that has already passed is refused when the op is staged. If a one-off's time passes while the chat is still open, publishing creates the task **switched off** with the reason on it rather than dropping it, so it is never lost.
3008
3130
  - Draft-created ids use `temp_sched_*`; use them with `get`, `update`, or `delete` before publish.
3009
3131
  - Staged commands require `BAKER_CHAT_ID` to point to an `in_progress` chat.
3010
3132
  - `trigger` rejects `temp_sched_*` and only accepts published scheduled-action ids.
@@ -4291,7 +4413,7 @@ There is no `prompt` param — the extraction schema is the node. Use `focus` to
4291
4413
 
4292
4414
  ##### `image_search`
4293
4415
 
4294
- Search the web for real images instead of generating them. A backend LLM agent picks among Google Images, stock photography (Freepik), and Pinterest, refines queries, selects the best matches, and the chosen images are downloaded into canvas assets. Use it to gather references or candidates (e.g. five photos of an australian shepherd) for a later pick-the-best step.
4416
+ Search the web for real images instead of generating them. A backend LLM agent picks among Google Images, free stock photography (Pexels and Pixabay), and Pinterest, refines queries, selects the best matches, and the chosen images are downloaded into canvas assets. Use it to gather references or candidates (e.g. five photos of an australian shepherd) for a later pick-the-best step.
4295
4417
 
4296
4418
  **Inputs** — none.
4297
4419
 
@@ -5752,6 +5874,10 @@ baker landing critique spring-offer summer-offer claude # score three landings
5752
5874
 
5753
5875
  - **Agent washing is a copy tell.** A page that claims autonomy ("fully autonomous", "while you sleep", "no human intervention") and never says anywhere what the buyer approves, overrides, or audits scores an `agent-washing` warn. One signal about oversight of the agent's actions — approval, review, override, undo/rollback, audit log, escalation — anywhere on the page clears it; the rule is page-scope precisely because the hero can defer the trust story to a section below it. Generic privacy boilerplate ("GDPR", "encrypted", "your data") deliberately does **not** clear it, since it appears in most footers and says nothing about who is in charge of the agent.
5754
5876
 
5877
+ - **Positioning integrity is scored too, and it is the one copy rule that blocks on a single sentence.** `competitor-concession` fires when the page ranks somebody else above the client, or says the client does not lead at what the page sells ("su constructor es mejor que el nuestro", "we're not the best"). Every other prose rule waits for a cluster because one AI tell proves nothing; this one is the whole defect on its own — the page is paid traffic, and it is arguing the visitor should leave. Two shapes that read identically are exempt by design: a concession about **price**, in either direction ("we are not the cheapest / the most expensive, and here is why" is premium positioning), and a superlative flipped by a negation ("ningún equipo trabaja más rápido que el nuestro"), which is the strongest claim *for* the client. Its warn-tier sibling `competitor-named-in-comparison` fires when a name from `src/content/competitors/` appears in a comparative sentence outside a `<table>` — a comparison table is a section the landing skill teaches, but naming a rival anywhere else on paid traffic is the client's call, not the build's. Spanish, Portuguese and English.
5878
+
5879
+ - **Copy held in component frontmatter is read like markup copy.** Astro landings keep FAQ pairs, testimonial quotes, feature cards and pricing rows as a `const items = [{ q, a }]` array above the `---` fence and render them in a `.map()`, so a critic that blanked the whole block was blind to a large share of every page's actual words. Prose string literals in that block now feed every copy rule; imports, class strings, URLs and config do not, and the bar is deliberately high in that direction — a Tailwind class list in front of the copy rules is worse than one missed tell.
5880
+
5755
5881
  Output is the standard envelope `{ ok, data, hints }` with `data = { advisory, slug, overall, counts, dimensions, findings }`. `dimensions` scores seven design families (typography, color, borders_depth, motion, spacing, copy, integrity) 0–1 (higher is better); `counts` is the block/warn/advisory tally.
5756
5882
 
5757
5883
  ## Brand Fonts (`baker brand fonts`)
@@ -5874,6 +6000,38 @@ Pushing to `main` with changes in `packages/cli/` triggers the GitHub Actions wo
5874
6000
  ./scripts/publish-package.sh cli next # Publish as @next (pre-release)
5875
6001
  ```
5876
6002
 
6003
+ - **0.243.0**: `baker ads linkedin account url-params` stages the URL tracking parameters on the whole **ad account** — the "Account tracking parameters" box in Campaign Manager, which the CLI (and this README, and the playbook) said was UI-only and unreachable through the API. It was, until LinkedIn's 202608 added `sponsoredAccount` to the `adEntity` key of `/adTrackingParameters`; Baker's LinkedIn version pin moves 202606 → 202608 with it. Same flags as the ad-set form (`--param`, `--dynamic`, `--clear`, `--file`) and no positional: the target is built from `--account-id`, because a bare numeric id has always meant an ad set and reading one as an account would silently stage the wrong write. **LinkedIn concatenates the two levels rather than overriding**, so a key set at both appears twice in the served URL — the stage warnings now name the other level for exactly that check, and the staged change says which level it applies to. `baker ads linkedin account` keeps reading account detail unchanged (it is now `account detail`, kept as the default subcommand).
6004
+
6005
+ - **0.244.0**: `baker ads linkedin url-params` **reads** the tracking parameters in effect — the ad account's, optionally every ad set's, and the keys set at BOTH levels. There was no read at all before: the only way to see what an entity carried was to stage a change and look at the before-state, which meant the concatenation trap (a key at the account AND an ad set lands in the served URL twice) was invisible until a report came back split across two values of the same parameter. Account-only by default because `adTrackingParameters` has no batch read, so each ad set is its own call; `--ad-sets` sweeps them, capped at 50, and the response carries `adSetCoverage: {examined, total, capped}` so a partial sweep can't read as a clean account. `--collisions-only` for just the finding.
6006
+
6007
+ - **0.245.0**: **a repeated flag no longer loses everything but its last value.** citty declares no array arg type — it calls `util.parseArgs` without `multiple: true` — so `--param utm_source=linkedin --param utm_medium=paid` reached the command as the single string `utm_medium=paid`. The arg descriptions said "repeatable", the unit tests passed arrays citty never produces, and `url-params` REPLACES an entity's whole parameter set: staging three UTMs set one and deleted the rest at publish, reporting success throughout. Found by staging against the real ad account and reading the payload back, not by any test. Every repeatable flag now reads its values off the command line: LinkedIn `--param`/`--dynamic`, Google Ads `--custom-param` (Google replaces the set wholesale too) and `--text`, `baker mcp add --header`, and `baker flows map --map`, which had already solved this locally and is now the shared helper.
6008
+
6009
+ - **0.245.0**: `baker ads linkedin url-params --campaign-ids` says when an id names no ad set. LinkedIn answers NOT_FOUND identically for "this ad set carries no parameters" and "there is no such ad set", so a mistyped id came back as an ad set that exists and is empty — and the next move on that is a write that only fails at publish. Such ids are now reported under `unreadable` with the reason, and the "nothing is tagged" hint is suppressed, exactly as for a level that could not be read.
6010
+
6011
+ - **0.245.0**: a backend condition the caller can act on reaches the caller. "Analytics reporting is not switched on for this workspace yet" was thrown as an `INTERNAL`, and the HTTP layer withholds the message of every 5xx so a real fault cannot leak its internals — so the CLI printed `INTERNAL_ERROR: Internal server error` and, worse, LinkedIn and Meta commands marked it retryable. It is now a `CONFLICT`, which the CLI maps to a new non-retryable `CONFLICT` code and prints verbatim.
6012
+
6013
+ - **0.246.0**: `baker ads linkedin draft` reports what a publish DID. It used to render a published draft exactly like a staged one — same "1 staged change", same tree, no mention of the outcome — even though the per-op `result` was in the response the whole time. So the one surface an agent reaches for to check a publish could not answer, and a publish that changed LinkedIn and then failed to finish looked identical to one that had not run yet. It now reads `published · 1 applied` / `2 applied, 1 failed`, names each failed or skipped op with its error, and says `publishing` while one is still in flight. Found because a tracking-parameter publish did exactly that: applied at LinkedIn, then left the draft stuck, with the only trace an `Error calling onComplete` in the server log.
6014
+
6015
+ - **0.247.0**: a tracking-parameter write with no `--dynamic` no longer fails at publish. LinkedIn's `/adTrackingParameters` record requires BOTH maps and rejects a body missing either — `ERROR :: /dynamicValueParameters :: field is required but not found and has no default value` — so the most ordinary write there is, a couple of fixed UTMs and no placeholder, was a hard failure, while every write that happened to carry a `--dynamic` succeeded. Both maps are now always sent, empty when unused. Found by the 0.246.0 outcome line, on the first real publish that did not pass `--dynamic`.
6016
+
6017
+ - **0.248.0**: a tracking-parameter read that fails no longer reads as "there is nothing there". The idempotency token is LinkedIn's concurrency guard AND the only look at the before-state, and `url-params` REPLACES the entity's whole set — so writing without it is not a degraded write, it is an unguarded one that overwrites live parameters having never seen them. Both readers collapsed every failure into "none set": the executor logged `not readable; writing as a fresh set` and did exactly that, and the stage-time snapshot logged `previewing as none set`, which silently removed the drop warning that exists to catch this. Only LinkedIn's NOT_FOUND now means "none yet" (a first write — and no longer logged, since it is the normal case that was filling the log). Any other failure fails the publish, and at stage time produces a warning saying the current parameters could not be read rather than none being there. It matters most on the 409 retry: a 409 means somebody changed the parameters between our read and our write, and a swallowed failure on the re-read turned that detected conflict into a blind overwrite of their change.
6018
+
6019
+ Reading it back changes three things beyond the command. **`campaigns url-params` / `account url-params` now warn by name when a write would delete a live key** — LinkedIn replaces an entity's whole parameter set rather than merging into it, so staging one pair on an ad set that already carries three published as "one pair, and the other three are gone". Pairs accumulate inside a chat, which is exactly what made a merge at publish the natural expectation; the stage warning names what would be dropped, and the guidance now says to read first and re-send everything that should survive. **`baker analytics map` can check a proposed LinkedIn mapping against the account** — LinkedIn was on the list of platforms with no configuration read, so its mappings shipped with no evidence behind them; it now reads the account level and up to ten ad sets. And **the `tracking.campaign_parameters` audit check answers before a campaign runs**: with no traffic it used to be `n_a`, honest but useless at the one moment the fix is free, and it now judges the account's own parameters — with wording that says so rather than claiming clicks arrived carrying nothing when no click arrived.
6020
+
6021
+ Three more things the 202608 pin brought with it. **The conversion-type enum went from 8 values to all 34 LinkedIn accepts** — `QUALIFIED_LEAD`, `BOOK_APPOINTMENT`, `SUBMIT_APPLICATION` and 23 others were rejected by our own schema before the request ever left, and 202608 adds `MARKETING_QUALIFIED_LEAD` / `SALES_QUALIFIED_LEAD` on top. **Attribution windows gained 365** (and view-through gained 90), with the type-conditional rule enforced on `conversions create`: 365 is only legal for `SUBMIT_APPLICATION`, `PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, `LEAD`. And **a 429 carrying `SEGMENT_LIMIT_EXCEEDED` is no longer reported as a throttle** — 202608 caps an ad account at 1,000 DMP segments and reuses the rate-limit status for it, so an agent that read it as "retry in a second" would have retried forever against a cap only a human can clear in Campaign Manager.
6022
+
6023
+ - **0.256.0**: **`assets attach` no longer offers an image link, because Google accepts none.** A production publish came back with six `AssetLinkError.FIELD_TYPE_INCOMPATIBLE_WITH_CAMPAIGN_TYPE` rows (trigger `MARKETING_IMAGE,SEARCH`) on an otherwise built and live Search campaign — staged exactly as this CLI's own advisory and the scaffold's ads-google doc instructed, at `"fieldType": "MARKETING_IMAGE"`. The obvious fix was `AD_IMAGE`, which is what Google's documentation and its own forum answers name for a Search image extension. **It does not work either.** Sent to a real account on v23, all four combinations are refused: `MARKETING_IMAGE` on a Search `CampaignAsset` → `FIELD_TYPE_INCOMPATIBLE_WITH_CAMPAIGN_TYPE`; `MARKETING_IMAGE` on a Search `AdGroupAsset` → `UNSUPPORTED_FIELD_TYPE`; `AD_IMAGE` on a `CampaignAsset` → `UNSUPPORTED_FIELD_TYPE`; `AD_IMAGE` on an `AdGroupAsset` → `UNSUPPORTED_FIELD_TYPE`. Image extensions never moved to asset-based extensions, so there is no request the API takes. Every image field type is now refused at the CLI boundary with the interface named instead of another field type to try, and the **"recommend at least 3 image assets" advisory is removed** — it had been firing on every Search campaign since 0.191.0 and could not be satisfied by any draft, because the write it named has never once succeeded. Performance Max asset-group images are a different mechanism and are untouched (`asset-groups attach`). Four force-synced scaffold surfaces were saying the same wrong thing and now agree with the API: the `ads-google.md` tool doc, the `assets` command group description, the account-audit playbook's Dynamic Images row, and `staged-writes.md`. An audit should still raise a missing-images gap — it is a real SERP disadvantage — it just has to name the Google Ads interface as the fix rather than a command.
6024
+
6025
+ - **0.256.0**: a structured snippet value over 25 characters is refused before it is staged. Google caps each VALUE at 25 and answers a longer one with a bare `Too long.` at `structured_snippet_asset.values[i]` — no header, no value, nothing to act on — and it takes the asset's campaign link down with it as `RESOURCE_NOT_FOUND`. `assets create --values` and `assets update --values` now cap each entry; the per-asset 3-10 count check was already there, and only the per-value length was missing. Found on a live publish where four of five values were fine and "Bases de datos vectoriales" (26) was not, and confirmed fixed by re-publishing the same snippet at 25 characters against a real account, where it applied and linked cleanly.
6026
+
6027
+ - **0.274.0**: `baker landing critique` gains a `positioning integrity` pair in the `integrity` family, and starts reading the copy Astro components keep in their frontmatter. `competitor-concession` is **block-tier and fires at a single sentence** — unlike every other prose rule, which needs a cluster — because a page that ranks a competitor above the client, or says the client does not lead at what it sells, spends the client's ad budget arguing the visitor should leave. A concession about **price** in either direction never fires it ("we are not the cheapest / the most expensive, and here is why" is premium positioning), and neither does a superlative flipped by a negation ("ningún equipo trabaja más rápido que el nuestro"), which is the strongest claim FOR the client. Its warn-tier sibling `competitor-named-in-comparison` fires when a name from `src/content/competitors/` appears in a comparative sentence outside a `<table>` — naming a rival on paid traffic is the client's call. Both read Spanish, Portuguese and English. Alongside them, prose string literals in a component's `---` block are now scored like markup copy (imports, class strings, URLs and config still are not), so an FAQ held as `const faqs = [{ q, a }]` — the standard Astro shape — is no longer invisible to the critic and to `BRAND.md` copy rules. `CRITIC_VERSION` bumps to `3`.
6028
+
6029
+ - **0.273.0**: **an ad's name can no longer take a whole publish down.** `Ad.name` is IMMUTABLE, and `--ad-name` is shared by `ads create` and `ads update` (every content flag is), so an update restating the ad's name put `name` in the field mask and Google refused the operation — and, being one op inside an atomic batch, everything staged with it. The name is now dropped from an update's mutate, and an update that would actually RENAME the ad is refused at stage time instead, so a rename is answered rather than silently ignored. Two related corrections: `--ad-name` is required on `demandGenVideo` **only** — a multi-asset `demandGen` ad published with no name at all and Google created it, so the docs that called it a Demand-Gen-family requirement were wrong — and a content flag on `--format call` is now refused like every other format's, instead of being dropped because a call ad reads its content from `--file` alone. The Demand Gen video ad's live videos and logos are also read into the review card now, so an update that replaces them shows what it is replacing.
6030
+
6031
+ - **0.272.0**: what one live publish of a Demand Gen video ad found, all four fixed and each of them a whole-batch failure (a campaign, its ad group, its ad and its assets are one atomic mutate). `--type DEMAND_GEN_AD` and `--type VIDEO_OUTSTREAM` are **gone from `ad-groups create`** — v23's ad group types have never had either, and Google answers with a request-level `Invalid value` that fails every sibling operation. `--type` is now **optional and best omitted**: Google derives an ad group's type from its campaign's channel, and on a Demand Gen campaign that is the only way to create one (its `ad_group.type` comes back empty). `--bidding-strategy MANUAL_CPC` on a **Demand Gen** campaign is refused before staging — Google rejects it outright (`OPERATION_NOT_PERMITTED_FOR_CONTEXT`), and the Demand Gen playbook shipped in every sandbox was teaching it; `MAXIMIZE_CONVERSIONS` and `TARGET_CPC` work. And a new Demand Gen video ad now needs `--ad-name` (Google's "Ad name", required on Demand Gen ads and no other format) and at least one `--logo-image-assets` ref alongside its video — Google only says so at publish, once the assets in the same batch have already been created.
6032
+
6033
+ - **0.271.0**: **`--format demandGenVideo` — the Demand Gen ad most Demand Gen spend actually runs through can be edited.** A `DEMAND_GEN_VIDEO_RESPONSIVE_AD` keeps its copy in `demandGenVideoResponsiveAd`, which has a `longHeadlines` list no other ad format has and takes its business name as a text asset rather than a string. Baker modelled only the multi-asset Demand Gen ad and labelled all four Demand Gen types as one format, so every served video ad was uneditable: the strict content schema refused `longHeadlines` and named a field instead of a missing format, and the only route left was the Google Ads interface. On the account that surfaced it, Demand Gen was 76% of the spend. New flags on `ads create|update`: `--long-headlines` (up to 5 × 90 chars, this format only), `--video-assets`, `--logo-image-assets`, `--call-to-action-assets`; a create with no video is refused with the `assets create` call to make first, and an omitted media list on an update leaves that media on the ad. Two more gates ship with it. **A content flag a format does not read is now refused instead of dropped** — `--long-headlines` on `--format demandGen` staged the copy without them and reported success, the same silent-drop the backend's strict schemas exist to stop. And **the format is checked against the live `ad_group_ad.ad.type` when it stages**: a mismatch is refused with the right format named, and the two Demand Gen types Baker writes no format for (`DEMAND_GEN_CAROUSEL_AD`, `DEMAND_GEN_PRODUCT_AD`) are refused by name, so "Baker cannot edit this ad" arrives at stage time rather than as a publish failure a Session too late.
6034
+
5877
6035
  ### Testing a pre-release in sandboxes
5878
6036
 
5879
6037
  ```bash
@@ -2,10 +2,10 @@ import {
2
2
  handleConnectionError,
3
3
  needsConnectionFix,
4
4
  writeAdsJson
5
- } from "./chunk-XAUTT2Q6.js";
5
+ } from "./chunk-A2VZOTCV.js";
6
6
  import {
7
7
  ApiError
8
- } from "./chunk-P2PEABMQ.js";
8
+ } from "./chunk-KDTHRRAC.js";
9
9
  import {
10
10
  getEnv
11
11
  } from "./chunk-WFWU3CHS.js";
@@ -108,4 +108,4 @@ export {
108
108
  csvOrJson,
109
109
  resolveEffectiveStatus
110
110
  };
111
- //# sourceMappingURL=chunk-RHEQTHT7.js.map
111
+ //# sourceMappingURL=chunk-4YF56EGK.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  ApiError,
3
3
  apiGet
4
- } from "./chunk-P2PEABMQ.js";
4
+ } from "./chunk-KDTHRRAC.js";
5
5
  import {
6
6
  getEnv
7
7
  } from "./chunk-WFWU3CHS.js";
@@ -11,7 +11,9 @@ var REQUEST_CONNECTION_PLATFORM = {
11
11
  google_ads: "google-ads",
12
12
  ga4: "google-analytics",
13
13
  gsc: "google-search-console",
14
- linkedin_ads: "linkedin"
14
+ linkedin_ads: "linkedin",
15
+ meta_ads: "meta",
16
+ x_ads: "x-ads"
15
17
  };
16
18
  var PLATFORM_NAME = {
17
19
  google_ads: "Google Ads",
@@ -24,9 +26,6 @@ var PLATFORM_NAME = {
24
26
  function connectionFixExplanation(platform) {
25
27
  const name = PLATFORM_NAME[platform];
26
28
  const slug = REQUEST_CONNECTION_PLATFORM[platform];
27
- if (slug === void 0) {
28
- return `${name} is not connected for this company, and it cannot be connected from the chat yet. Tell the user to connect it in the dashboard under Brain \u2192 Integrations \u2192 Tools \u2192 ${name}. Do not fail the whole job: say what stays unavailable and carry on with the rest.`;
29
- }
30
29
  return `${name} is not connected for this company (or no account has been picked). Do NOT send the user to Settings and do NOT end the turn here \u2014 call the \`request_connection\` tool with { platform: "${slug}", reason } and they connect and pick their accounts from inside the chat. If that tool is not available to you, tell them exactly where to do it: dashboard \u2192 Brain \u2192 Integrations \u2192 Tools \u2192 ${name}. Either way, carry on with the rest of the task and say what stays unavailable until it is connected.`;
31
30
  }
32
31
  function handleConnectionError(platform, originalMessage) {
@@ -53,7 +52,7 @@ function handleConnectionError(platform, originalMessage) {
53
52
  code,
54
53
  message: originalMessage || `No ${platformName} connection found for this company.`,
55
54
  fix: {
56
- action: REQUEST_CONNECTION_PLATFORM[platform] === void 0 ? "ask_user" : "request_connection",
55
+ action: "request_connection",
57
56
  explanation: `${connectionFixExplanation(platform)} (Optional fallback for estimated external insights: 'baker research ${alternative}'.)`
58
57
  },
59
58
  retryable: false
@@ -295,4 +294,4 @@ export {
295
294
  writeAdsOutput,
296
295
  resolveCustomerId
297
296
  };
298
- //# sourceMappingURL=chunk-XAUTT2Q6.js.map
297
+ //# sourceMappingURL=chunk-A2VZOTCV.js.map