@koda-sl/baker-cli 0.305.0-dev.a04fd407d → 0.306.0-dev.bb28562ac

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
@@ -352,6 +352,7 @@ The same lines also go to stderr. A read with nothing to report prints none of i
352
352
  | Preset | Description | Default date range |
353
353
  |------------------------|------------------------------------------|--------------------|
354
354
  | `campaign-performance` | Campaign metrics overview | LAST_30_DAYS |
355
+ | `conversions-by-action` | Which conversion action each campaign's conversions actually are | LAST_30_DAYS |
355
356
  | `keyword-analysis` | Keyword performance per ad group | LAST_30_DAYS |
356
357
  | `keyword-serving` | Why a keyword is limited — status reasons, first-page bid estimate vs max CPC, quality score | ALL_TIME |
357
358
  | `positive-keywords` | Positive (targeting) keywords only | ALL_TIME |
@@ -366,6 +367,8 @@ The same lines also go to stderr. A read with nothing to report prints none of i
366
367
 
367
368
  Every preset except the negatives lists and `account-summary` filters to **actually-serving entities** by default — the full status chain (`campaign.status = 'ENABLED' AND ad_group.status = 'ENABLED' AND ad_group_ad.status = 'ENABLED'`, as applicable) — and selects the status columns so you can see them. Pass `--include-paused` to widen to `!= 'REMOVED'` (keeps paused, still drops removed). Raw GAQL (no `--preset`) has no such default: a query over a serving-hierarchy resource without a status filter emits a `SERVING_SCOPE` warning reminding you to add the chain, because Google Ads has no single serving flag.
368
369
 
370
+ **Conversion rates across campaigns are not comparable by default.** `metrics.conversions` is a sum over whatever conversion actions are primary for each campaign, and those differ campaign by campaign — a webinar registration in one, an info request in the next. A read that selects a conversion metric, is not segmented by conversion action, and is not pinned to one campaign (`WHERE campaign.id = …`) comes back with a `CONVERSION_MIX` warning: a total across those rows adds different events, and one campaign's rate is not comparable with another's. `--preset conversions-by-action` is the read that settles it. Like every other warning it travels in `--output csv|jsonl|md` and on the `--out` file summary.
371
+
369
372
  **"Eligible (Limited)" keywords:** an enabled keyword that barely shows is invisible in the performance presets — near-zero impressions look the same whether it is outbid, low quality, or rarely searched. `--preset keyword-serving` reads the signals behind Google Ads' status column: `ad_group_criterion.primary_status` and `primary_status_reasons` (the status enum has no `LIMITED` value — "(Limited)" lives in the reasons while the status stays `ELIGIBLE`), `position_estimates.first_page_cpc_micros` (the number in "Below first page bid (€3.63)"), the max CPC actually in force and where it comes from, quality score split into ad relevance / landing page / expected CTR, approval status, and `system_serving_status`. Any read whose rows carry those fields — preset or raw GAQL — comes back with a `KEYWORD_SERVING_LIMITED` warning naming the affected keywords (each by its ad group, since one keyword's copies share a criterion id) and the bid each would need. Below-first-page-bid and low-quality want opposite fixes (raise the bid vs. improve relevance), so the warning reports them separately. On `--out` the verdict is folded across pages as they stream to disk and returned on the file summary; like every other warning it also travels in `--output csv|jsonl|md`.
370
373
 
371
374
  The warning defers to Google: when the query selects `primary_status_reasons`, a keyword Google did not flag is not reported, even if its bid sits under the first-page estimate — the estimate is advisory and the bid may not be the criterion's to set under a portfolio strategy. Only an estimates-only read (no reasons selected) falls back to inferring the limit from the bid gap.
@@ -1265,6 +1268,9 @@ baker analytics conversions --event page:a --count-mode every_time # re
1265
1268
  baker analytics conversions --rename "Lead form" --to "Lead" # merge two into one row
1266
1269
  baker analytics conversions --remove page:request_demo
1267
1270
  baker analytics conversions --remove-name "Lead"
1271
+ baker analytics sites # the websites the measurement tag is accepted from
1272
+ baker analytics sites --add example.com # and every subdomain of it — applies at once, no publish
1273
+ baker analytics website-tag # the tag for a site Baker does not publish, with the key in it
1268
1274
  baker analytics delivery --page 2 # the next page of a long list
1269
1275
  baker analytics submissions --flow contact # every attempt at a Form, delivered or not
1270
1276
  ```
@@ -1277,6 +1283,13 @@ Shared flags: `--days <n>` (default 30) or `--start-date` / `--end-date` (`YYYY-
1277
1283
  - 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.
1278
1284
  - Past ~25 rows the response carries a hint naming the row count and pointing at `--output md`.
1279
1285
 
1286
+ **Measuring a website Baker does not publish.** `baker analytics website-tag` returns the tag with this company's site key in it, the websites it is accepted from, and the whole install as Markdown. Two things decide whether it does anything:
1287
+
1288
+ - **`baker analytics sites` first.** The key is public and is **silently ignored** from any origin not on the declared list, so a tag on an undeclared website is a perfect install that measures nothing, with no failing request anywhere. `--add` / `--remove` are repeatable, apply immediately, need no publish, and a tag already installed starts being accepted at once. Declare the **domain**, not the subdomain you happen to be installing on: one entry covers everything under it (`example.com` answers for `www.`, `go.` and `shop.eu.`) and it never widens upward. The response also carries `suggestedOrigins` — domains Baker already serves this company's landings on that nothing covers yet.
1289
+ - **The snippet is two scripts and both are required.** The first creates `window.baker` synchronously and queues calls; the second is the tag, which is async. Paste only the second and any outcome reported before it finishes loading reaches nothing — which on a thank-you page is the normal case. For the same reason, never guard a call with `if (!window.baker) return`: on the fastest pages that is always true, and it throws the conversion away with no error anywhere. Call `window.baker.track(…)` directly.
1290
+
1291
+ `--measure` decides whose activity is measured and defaults to `campaigns` (only visitors a Baker landing brought). `--measure all` measures the client's entire website and is a decision somebody has to ask for. `host` is the client's own landing domain when one shares a domain with the site being measured, so the script and its events stay first-party; `firstParty` in the response says which, and removing that domain from Baker means re-running this command.
1292
+
1280
1293
  **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
1294
 
1282
1295
  **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.
@@ -1342,6 +1355,8 @@ baker experiment fold # make the page
1342
1355
 
1343
1356
  **The goal is one event, named by its key.** `--goal submit:book-a-call` — the same vocabulary the event stream uses (`form:<flow>:<node>:<trigger>`, `submit:<flow>`, `page:<name>`, `exit:<host>`), and `baker analytics conversions --candidates` lists the keys these pages produce. Not a conversion *name*, which groups several keys: a variant that swaps the page's Form for a shorter one converts on a different key, and a name would either count both endings as one outcome or count the new version as never converting at all — `--variant-goal` gives the new version its own event when it has one. The goal is fixed for the life of the test and the verdict is read on it alone. Every other conversion the company counts comes back under `secondary` on every result, with the same interval and a one-word reading, and decides nothing. A conversion counts for a visitor wherever it happens after they were shown a version — on the page, on another page, in a delivered lead, or in a deal the CRM posted a week later by email.
1344
1357
 
1358
+ **`start` checks that both versions still fire a conversion on the same action.** A conversion is a name matched against each page's own `data-baker-*` declarations, so the two versions agree about what an event means only because a person made them agree — and nothing downstream can tell when they stop. `start` compares the sections the two versions do not share and names any conversion whose markup differs, because a step moved from a pop-up to the open page keeps its name and starts counting a scroll instead of a click. A forked section is supposed to change, so most of these are fine; the one that is not is the one nobody looked at. Give the new version's step its own name when it is a different action.
1359
+
1345
1360
  **Nothing ends a test on its own.** `status` returns a verdict; `finish --keep original|variant` is the decision, and it takes effect within seconds with nothing to publish. Ending a `keep_running` test records `inconclusive`. Going against the verdict is allowed and recorded. `pause` and `resume` switch the split off and on without losing the test; `cancel` is `finish --keep original` for a live test and takes a staged one back off the session. `update` changes what a test *says* — `--because`, `--change`, `--evidence` (repeatable, `source:what it showed`, optionally `|link`) — at any time, writes down what a finished one taught (`--learning`), and changes what it *measures* — `--goal`, `--lift`, `--expect` — only while it is still staged.
1346
1361
 
1347
1362
  **A finished test is a record.** Its counts are frozen when it ends, so it reads the same next year as it did that day; its `learning` is what it taught, in a person's words. `history` returns every finished test — the claim, what was different, the numbers with their interval and confidence, what was kept and whether that went against the numbers, whether the belief held, and the learning — and `plan` returns the same entries as `pastTests` for the page it sizes, so a hypothesis is chosen against the page's record rather than in ignorance of it.
@@ -2127,6 +2142,7 @@ Each external source is its own subcommand. Pick the verb that matches the sourc
2127
2142
  | `baker images get <id>` | Single record | n/a |
2128
2143
  | `baker images upload <file\|url>` | Upload a local file OR remote URL to the library | always on |
2129
2144
  | `baker images delete <id>` | Delete a record | n/a |
2145
+ | `baker images describe <id>` | Correct a record's stored name / description / tags | n/a |
2130
2146
  | `baker images normalize <files>` | Local recolor + bg removal + trim + resize | n/a (operates on disk, no upload) |
2131
2147
  | `baker images upscale <imageId>` | Real-ESRGAN super-resolution via backend ($0.05/image, cost-tracked) | n/a (operates on library image) |
2132
2148
  | `baker images layerize <imageId>` | Split into editable layers — transparent cutouts per element plus baked-in copy recovered as editable text (costs credits) | n/a (operates on library image) |
@@ -2567,6 +2583,20 @@ baker images delete j571abc123def --dry-run
2567
2583
  baker images delete j571abc123def
2568
2584
  ```
2569
2585
 
2586
+ ### `baker images describe <id>`
2587
+
2588
+ Correct what the library says an image is. The stored `description` is what `baker images library` retrieves on, so an AI description that came out wrong is what makes an asset unfindable — this is the fix, rather than another query.
2589
+
2590
+ Only the flags you pass change; everything else keeps its current value. `--tags` **replaces** the whole tag set rather than adding to it, so read the current one with `baker images get <id>` first if you mean to extend it. It is repeatable (`--tags a --tags b`) as well as comma-separated — both spellings keep every value.
2591
+
2592
+ ```bash
2593
+ baker images describe j571abc123def --description "Founder on stage at SaaStr, blue backdrop, mid-gesture"
2594
+ baker images describe j571abc123def --name "SaaStr keynote" --tags event,team
2595
+ baker images describe j571abc123def --description "…" --full # also returns the whole library row
2596
+ ```
2597
+
2598
+ The semantic index rebuilds in the background, so a `library` search run immediately afterwards may still rank on the old wording. The response says so in `hints`.
2599
+
2570
2600
  ### `baker images normalize <files> [options]`
2571
2601
 
2572
2602
  Local-file image normalization for logos and brand assets. Declarative — describe the desired final state via flags and the pipeline runs the needed stages in order. Operates on files in the sandbox; pair with `baker images upload` to ship results to the library. For super-resolution, upload first then use `baker images upscale <imageId>` (runs on the Convex backend, cost-tracked).
@@ -2833,6 +2863,15 @@ Requires `yt-dlp` on `PATH` (present in the Baker Runtime). Private, age-restric
2833
2863
 
2834
2864
  Delete a video by ID.
2835
2865
 
2866
+ ### `baker videos describe <id>`
2867
+
2868
+ The same correction for a clip: fix the stored `name`, `description` or `tags` when the analysis read the video wrong. Only the flags you pass change, `--tags` replaces the whole set, and the search index rebuilds in the background.
2869
+
2870
+ ```bash
2871
+ baker videos describe j571abc123def --description "Customer explaining how onboarding cut setup from a week to a day"
2872
+ baker videos describe j571abc123def --tags testimonial
2873
+ ```
2874
+
2836
2875
  ### `baker videos tags`
2837
2876
 
2838
2877
  List the available video tag names — built-in defaults plus the company's custom tags — straight from the backend. Use it before filtering with `--tags`. Defaults to a markdown list (`--output json` for the `{ ok, data }` envelope).
@@ -3216,6 +3255,7 @@ baker tags draft # review the staged changes awaiting publish
3216
3255
 
3217
3256
  Notes:
3218
3257
 
3258
+ - **Both reads restate the write path, on stderr.** `list` and `draft` end with the two lines that say the approval form is the only way to change a tag, that it rides on every turn, and that a tag change is never a Task. They are there because a chat concluded from its tool list alone that the form did not exist, never called it, and filed the change as a Task for the user to carry out by hand. stdout stays the list, so piping it is unaffected; `--json` carries the same lines in the envelope's `hints`.
3219
3259
  - **`list` prints each tag's full readable config.** Every non-secret field is shown in full under the tag — the Clarity `projectId`, GA4 `measurementId`, the entire custom `code` snippet, etc. — so the agent reuses a stored value to pre-fill a follow-up change instead of asking the user for something already installed. Secrets are never among these fields. (`--json` emits the same data as the raw envelope.)
3220
3260
  - **Secrets never travel through this CLI or the chat.** Secret fields (`accessToken`, `apiSecret`, `authorizationToken`, `apiKey`, `conversionToken`, `oauthProviderId`) are entered only in the dashboard's secure tag form and flow straight into the staged draft; tool responses only ever name which secret fields are set/pending.
3221
3261
  - Staged creates get a server-generated `tag_temp_*` ref (returned in the tool result and printed by `list`). Use it (or a real tag id) as flow side-effect `tagIds` — the published tag keeps resolving under the temp ref.
@@ -3796,7 +3836,10 @@ because a video model garbles a wordmark every time:
3796
3836
  - **`end_card`** — on by default whenever the spec carries a brand. The last beat's PICTURE
3797
3837
  becomes a flat brand colour plate (rendered by ffmpeg, not generated), with the mark and
3798
3838
  the call to action over it. `end_card: false` keeps the footage; `end_card: { cta }` sets
3799
- the words on the button, which otherwise default to the last line of the script.
3839
+ the words on the button. There is **no default**: with no `cta` the card carries the mark
3840
+ alone. It used to fall back to the last line of the script, which printed that sentence
3841
+ twice on the closing frame — once as the button and once as the caption of the line being
3842
+ spoken over it. Repeat the closing line as the `cta` and it is dropped for the same reason.
3800
3843
  - **The brand fills itself.** When the spec leaves `brand.logo` or `brand.palette` empty,
3801
3844
  they are read from the workspace — `src/brand/BRAND.md` for the colours (in document
3802
3845
  order, page-furniture whites and blacks dropped) and `src/brand/logos/` for the mark
@@ -4073,7 +4116,7 @@ Pick a `source` discriminator and declare the kind you expect. See [Ingestion](#
4073
4116
 
4074
4117
  **Params** — discriminated on `source`:
4075
4118
 
4076
- `source: "url"` (yt-dlp / direct fetch / Handinger):
4119
+ `source: "url"` (yt-dlp / direct fetch / Firecrawl):
4077
4120
 
4078
4121
  | Name | Type | Required | Constraint |
4079
4122
  |---|---|---|---|
@@ -4093,7 +4136,7 @@ Pick a `source` discriminator and declare the kind you expect. See [Ingestion](#
4093
4136
 
4094
4137
  **Path-source notes:** the canvas is **not portable** to another machine without the file. Cache key folds the file's `mtime:size`, so editing the file invalidates the cache automatically. Supported extensions: `png`, `jpg`/`jpeg`, `webp`, `gif`, `avif`, `svg`, `mp4`, `webm`, `mov`, `m4v`, `mp3`, `wav`, `m4a`, `ogg`, `flac`, `json`, `txt`, `md`, `markdown`, `html`/`htm`, `csv`, `ttf`, `otf`, `woff`, `woff2`. Unknown extensions fall back to magic-byte sniffing for common image formats (and an SVG content sniff), else `kind_mismatch`. **Any `expect: "image"` in a format image-generation models can't read (SVG, AVIF, HEIC, TIFF, BMP) is normalized to PNG on ingest** — model-safe rasters (`jpeg`/`png`/`gif`/`webp`) pass through untouched, everything else is transcoded so a reference can never 400 a generation. This applies to **both `source: "path"` and `source: "url"`** (URL images are fetched and normalized locally, since the backend can't run the rasterizer). SVG gets density-aware upscaling (longest edge near 2048px, transparency preserved). The normalized asset carries `metadata.rasterized_from` set to the source format (e.g. `"svg"`, `"avif"`). **Video (`expect: "video"`) duration is probed from the file's ISO-BMFF (`mp4`/`mov`/`m4v`) header** and stamped as the canonical `duration_ms` (and `metadata.duration_ms`); other containers (e.g. `webm`) leave it unset. Downstream `video_deconstruct` uses this declared duration to size its ingest-poll timeout and preflight — without it those fall back to worst-case budgets and a single deconstruct step can hit the action time limit.
4095
4138
 
4096
- **Cost:** 0 engine credits for direct fetch + yt-dlp + local file. Handinger charges per scrape.
4139
+ **Cost:** 0 engine credits for direct fetch + yt-dlp + local file. Firecrawl charges per scrape.
4097
4140
 
4098
4141
  ---
4099
4142
 
@@ -5407,10 +5450,10 @@ baker canvas catalog | jq '.compositions[].id'
5407
5450
  // URL source — YouTube audio-only (yt-dlp -x)
5408
5451
  { "id": "src", "type": "ingest", "params": { "source": "url", "url": "https://youtu.be/dQw4w9WgXcQ", "expect": "audio" } }
5409
5452
 
5410
- // URL source — blog post → clean markdown (Handinger)
5453
+ // URL source — blog post → clean markdown (Firecrawl)
5411
5454
  { "id": "src", "type": "ingest", "params": { "source": "url", "url": "https://example.com/blog/post", "expect": "text" } }
5412
5455
 
5413
- // URL source — PDF → markdown (Handinger handles PDFs)
5456
+ // URL source — PDF → markdown (Firecrawl handles PDFs)
5414
5457
  { "id": "src", "type": "ingest", "params": { "source": "url", "url": "https://example.com/whitepaper.pdf", "expect": "text" } }
5415
5458
 
5416
5459
  // URL source — raw markdown file (direct fetch)
@@ -5438,7 +5481,7 @@ baker canvas catalog | jq '.compositions[].id'
5438
5481
  | url | video / audio | any | `yt_dlp` (works on platform URLs *and* direct file URLs via the generic extractor) |
5439
5482
  | url | image / json / font | any | `direct_fetch` (HTTP GET) |
5440
5483
  | url | text | ends in `.txt` or `.md` | `direct_fetch` |
5441
- | url | text | anything else (HTML, PDF, …) | `handinger` `/markdown` (extracted markdown — HTML/PDF made readable) |
5484
+ | url | text | anything else (HTML, PDF, …) | `firecrawl` `/v2/scrape` (extracted markdown — HTML/PDF made readable) |
5442
5485
 
5443
5486
  If the bytes' mime contradicts `expect` (e.g. `expect: "image"` but the URL serves `text/html`, or a `.pdf` file is passed as `expect: "image"`), the node fails with a clear `kind_mismatch` error instead of forwarding bad bytes downstream.
5444
5487
 
@@ -5481,7 +5524,7 @@ If the bytes' mime contradicts `expect` (e.g. `expect: "image"` but the URL serv
5481
5524
  file_size?: number, // bytes
5482
5525
  original_filename?: string, // basename of the source path
5483
5526
 
5484
- strategy: "direct_fetch" | "handinger" | "yt_dlp" | "local_file",
5527
+ strategy: "direct_fetch" | "firecrawl" | "yt_dlp" | "local_file",
5485
5528
  ingested_at: string, // ISO 8601 timestamp
5486
5529
 
5487
5530
  // yt-dlp adds (when available from --dump-json):
@@ -5492,7 +5535,7 @@ If the bytes' mime contradicts `expect` (e.g. `expect: "image"` but the URL serv
5492
5535
  width?: number,
5493
5536
  height?: number,
5494
5537
 
5495
- // Handinger adds:
5538
+ // Firecrawl adds:
5496
5539
  word_count?: number,
5497
5540
  },
5498
5541
 
@@ -5511,8 +5554,8 @@ The `url` is a stable R2 URL — remote downstream nodes (e.g. `video_lipsync`)
5511
5554
  |-----------------------|----------------|------------------------------------------------------------------------------|
5512
5555
  | `INVALID_PARAMS` | engine validator | Missing/malformed fields, mixing `url` + `path`, unknown `source`, unknown `expect`. |
5513
5556
  | `kind_mismatch` | url + direct_fetch | URL returned bytes whose mime doesn't match `expect`. Message: `ingest expect=<X> but <url> returned <mime> (kind=<Y>)`. |
5514
- | `kind_mismatch` | path + local_file | File mime (from extension or magic-byte sniff) doesn't match `expect`, *or* mime doesn't map to any of our kinds (e.g. local PDFs aren't supported as `text` — host them via URL for Handinger to extract). |
5515
- | `provider_error` | url + handinger / direct_fetch | Handinger `/markdown` non-2xx, or upstream `fetch` non-ok. |
5557
+ | `kind_mismatch` | path + local_file | File mime (from extension or magic-byte sniff) doesn't match `expect`, *or* mime doesn't map to any of our kinds (e.g. local PDFs aren't supported as `text` — host them via URL for Firecrawl to extract). |
5558
+ | `provider_error` | url + firecrawl / direct_fetch | Firecrawl `/v2/scrape` non-2xx or no markdown in the response, **or the target site answering 4xx/5xx** (Firecrawl reports that inside a 200 carrying the error page, so it is refused rather than stored as the page), or upstream `fetch` non-ok. Terminal: a bad host, a 404 and a block page fail identically on a retry and each retry is billed. Retried: a 429 from either side, a Firecrawl-side 5xx, a dropped connection. |
5516
5559
  | `file_not_found` | path + local_file | `stat()` returned `ENOENT`. Error names the absolute resolved path. |
5517
5560
  | `not_a_file` | path + local_file | Path exists but is a directory / block device / socket. |
5518
5561
  | `permission_denied` | path + local_file | `stat()` or `readFile()` returned `EACCES`. |
@@ -5529,7 +5572,7 @@ All execution-time failures flow through `NodeExecutionError` so they appear in
5529
5572
  - **Engine-level cache** (`canvas/.cache/index/`) keys by node params + node version + node-supplied cache extras. Re-running the same canvas with the same params is a cache hit and skips the network/yt-dlp/local-read entirely.
5530
5573
  - **yt-dlp version is folded into the cache key** for `source: "url"` with `expect: video | audio`. Upgrading yt-dlp busts the cache automatically, since new versions can produce different bitstreams.
5531
5574
  - **Local file `mtime + size` is folded into the cache key** for `source: "path"`. Editing the file (any save bumps mtime) invalidates the cache so you get fresh ingestion. mtime+size is cheap to read at validate time; for cases where mtime is unreliable (`cp -p`, content-only changes), force-bypass with `--cache-policy bypass` or change the canvas's `cache_salt`.
5532
- - **No TTL.** Handinger and direct fetch don't cache-bust on page changes — if the source page updates, use `--cache-policy bypass` or change `cache_salt`.
5575
+ - **No TTL.** Firecrawl and direct fetch don't cache-bust on page changes — if the source page updates, use `--cache-policy bypass` or change `cache_salt`.
5533
5576
 
5534
5577
  **Local files (`source: "path"`):**
5535
5578
 
@@ -5552,10 +5595,10 @@ ingest { source: "path", expect: "video" } → video_transcribe # Local
5552
5595
  **Requirements:**
5553
5596
 
5554
5597
  - `yt-dlp` and `ffmpeg` on PATH for `source: "url"` with `expect: "video" | "audio"` (already in the E2B sandbox image; install locally with `pip install yt-dlp` and `brew install ffmpeg` / equivalent).
5555
- - `HANDINGER_API_KEY` on the Convex backend for `source: "url"` with `expect: "text"` on non-`.md`/`.txt` URLs.
5598
+ - `FIRECRAWL_API_KEY` on the Convex backend for `source: "url"` with `expect: "text"` on non-`.md`/`.txt` URLs.
5556
5599
  - No additional dependencies for `source: "path"` — just filesystem access.
5557
5600
 
5558
- **Cost:** zero engine credits for direct_fetch + yt_dlp. Handinger charges per scrape on your Handinger account (configured outside Baker).
5601
+ **Cost:** zero engine credits for direct_fetch + yt_dlp. Firecrawl charges per scrape on your Firecrawl account (configured outside Baker).
5559
5602
 
5560
5603
  ---
5561
5604
 
@@ -6044,6 +6087,9 @@ This CLI is designed for AI agent consumption. Key patterns:
6044
6087
  - **0.259.0**: `baker canvas scaffold-ad` is listed in `baker canvas --help` with a description, and the three video routes are separated in the skill. It had a usage line and no description row, so an agent reading help saw two scaffolders explained and a bare name — and three runs from the same prompt took three different wrong paths: hand-directing the flow, reproducing a competitor ad found via `winning-ads`, and hand-authoring a canvas. The routes now state their own boundary: `scaffold-ad` is the default for a new ad, `scaffold-video` only reproduces a video the USER supplied, and `video-flow.md` opens by saying so rather than calling itself the default eleven times.
6045
6088
  - **0.257.0**: the frame-vision pass now asks whether what is on screen could physically happen, not just whether the frame is empty. The defect that motivated it was a generated shot of a solar panel roughly five metres tall being lifted onto a roof by one person — well lit, on brief, perfectly legible, and impossible. That is what reads loudest as "AI-generated", and the previous question could not see it: nothing was missing, something present was the wrong size. Asked the new question, the pass called the rejected shot *"physically implausible due to the immense weight and surface area"* and the replacement *"consistent with what two people could reasonably maneuver"* — the same verdict a person reached, unprompted.
6046
6089
  - **0.256.0**: the pre-render gate reports caption cards that end mid-clause. The first cut of this check asked for consistency and got it the wrong way round — stripping terminal punctuation makes the cards agree and leaves them wrong, because "En solo nueve días tu casa" is not a line anyone wrote, it is a sentence halved by a word count. Captions split where the script punctuates; each card is then a clause carrying its own mark, grammatical, and consistent as a by-product. The last card is exempt, since a CTA legitimately ends bare. Reported, never rewritten — the copy belongs to whoever wrote it.
6090
+ - **0.297.0**: `baker images describe <id>` and `baker videos describe <id>` correct a library asset's stored `name`, `description` or `tags`. Until now `--context` at ingest time was the only way to influence a description, so an AI description that came out wrong was unfixable by an agent — and that description is what every `baker images library` / `baker videos search` retrieves on, which made the asset quietly unfindable rather than merely mislabelled. Only the flags passed are changed; the rest keep their stored values, because the dashboard's editor always has all three in a form and an agent fixing one sentence does not. `--tags` replaces the whole set (as the dashboard's does) and is repeatable as well as comma-separated, via the shared `repeatedFlags` helper — read off `rawArgs` because citty collapses a repeated flag to its last value, which on a wholesale-replace write deletes the rest. Compact by default; `--full` returns the whole library row in the same round trip. Both re-index in the background, so the response hints that an immediate re-search may still rank on the old wording. A row that is still being analysed is refused with `CONFLICT` rather than written: the describe pass overwrites `name`/`description`/`tags` with the model's own output when it lands, so a correction made in that window disappeared minutes after the CLI reported success. The dashboard never hit it because a non-ready card is not clickable; the agent's door had no such gate. An `error` row stays writable — nothing is scheduled against it.
6091
+
6092
+ ## Publishing
6047
6093
 
6048
6094
  ### Auto-publish (CI)
6049
6095
 
@@ -6088,8 +6134,16 @@ Pushing to `main` with changes in `packages/cli/` triggers the GitHub Actions wo
6088
6134
 
6089
6135
  - **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.
6090
6136
 
6137
+ - **0.293.0**: an `assets detach` whose asset link is not on the account is refused **at stage time**, and an asset-link read that leaves the link's own status unscoped is refused in preflight. Both come from one publish: 74 of 85 staged ops came back `RESOURCE_NOT_FOUND` — 67 ad-group sitelinks and 7 campaign ones — twelve hours after the agent reported them staged, against a Session that had already merged. Nothing checked them: `google.assetLink.detach` had no entry in the stage-time snapshot map, so its target read back as "read failed" (silent) instead of "not found" (refused), exactly the gap that was closed for `audienceCriterion`. The read that produced the list had the same hole from the other side — it scoped `campaign.status` and `ad_group.status` and said nothing about `campaign_asset.status` / `ad_group_asset.status`, and Google returns a removed link like any other row, so already-detached extensions read as live. The snapshot query excludes `REMOVED` for that reason: a retired link is zero rows, which is the refusal. A bare-id target still only warns — it names no level, so there is no row to read and a refusal would be a guess.
6138
+
6139
+ Two rules the new chains brought with them. **`campaign_asset` and `ad_group_asset` segment on their owners**, so a `campaign.*` / `ad_group.*` field used in WHERE must also be SELECTed or Google refuses the whole statement (`EXPECTED_REFERENCED_FIELD_IN_SELECT_CLAUSE`) — the rule that already applied to `campaign_budget`, now keyed off a table rather than one resource, and applied to the query `INCOMPLETE_STATUS_CHAIN` hands back so the corrected command is one you can actually run. Checked live on v23 against 3865083115: `ad_group_ad`, `ad_group_criterion` and `asset_group_asset` filter their ancestors freely, these two do not. And **`asset_group_asset` carries the same status** its campaign and asset group do not, so its chain gained `asset_group_asset.status` too — the Performance Max `asset-performance` preset scopes and shows it.
6140
+
6141
+ Underneath both: **`query` now returns a `.resource_name` you selected.** Google injects `resource_name` into every row whether or not it was asked for, and the backend stripped all of them as noise — including the one the SELECT clause named. For an asset link that column *is* the identity (`customers/{cid}/adGroupAssets/{adGroupId}~{assetId}~{FIELD_TYPE}`; there is no id to detach by), so the read the docs prescribe — `SELECT ad_group_asset.resource_name …` — came back without it and composing the string by hand was the only route left. Auto-injected resource names are still stripped; a selected one is kept. This also repairs the `asset-groups detach` instruction, which pointed at `asset_group_asset.resource_name` and could not have worked either.
6142
+
6091
6143
  - **0.291.0**: **the image handover Task no longer depends on having uploaded the images.** It was built only from staged `asset.create` image ops, so a Session that audited a live account — found the brand campaign carrying 22 image extensions and the generic one carrying none, and reached for `assets attach` on assets that were *already in the library* — got Google's refusal and nothing else: no upload to hang a Task on, and a CLI hint telling the agent not to raise one by hand because "Baker stages it itself". The one step only the user can take reached them as a sentence in a chat that scrolls away. An image `assets attach` is now **skipped rather than refused**: it still never reaches Google (re-verified live with `validate_only` on v23 *and* v25 against 3865083115, against a control op that validates clean in the same request — `AD_IMAGE` → `UNSUPPORTED_FIELD_TYPE` at customer and campaign level, `MARKETING_IMAGE` → `FIELD_TYPE_INCOMPATIBLE_WITH_CAMPAIGN_TYPE`, `BUSINESS_LOGO` → `CUSTOMER_NOT_VERIFIED`, which is what proves the field type is the objection; **we are not on Google's allowlist for it**), but the skip carries the image and campaign names, recorded on the draft so the next staged op cannot recompute the Task away, and a batch that also carries sitelinks keeps them instead of failing whole. The schema stopped refusing the field type for the same reason: a boundary refusal has no chat to record a handover against.
6092
6144
 
6145
+ - **0.306.0**: review fixes across the avatar and ad paths. **A silent clip is 5 seconds again** — the 8s speaking floor was applied to every `studio animate` call, not just ones that cast an avatar, so plain b-roll was lengthened by three seconds and billed for them. **`scaffold-ad` validates the mark it will actually draw**: the logo gates read the path the spec named rather than the one `fetchClientLogo` had just put there, so a fetched mark either skipped both checks or was refused as unreadable. **The render review is told where the ad is set, who it is for and what the script says** — read off the blueprint beside the canvas; without them the watch pass judged an accent against nothing and still graded the verdict as blocking. **A beat is "too short" by its LINE, not by the clip floor** — the old test needed a 25-word clause, so it fired on all three beats of this command's own documented example. **A voiceover stays a voiceover** — a phrase nobody is shown speaking was being folded into the presenter's clip and said on camera, words that shot was never generated for; the fold now applies only where there is a mouth for the words to contradict. `--voice-id` / `--voice-description` are gone from `baker avatars create` (0.252.0 said so; the flags were still declared and still sent), `end_card.cta` is documented as having no default, and the dead Veo/Omni content-block fallbacks are removed.
6146
+
6093
6147
  ### Testing a pre-release in sandboxes
6094
6148
 
6095
6149
  ```bash
@@ -17,9 +17,9 @@ import {
17
17
  shouldEscalate
18
18
  } from "./chunk-WFWU3CHS.js";
19
19
 
20
- // ../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/88b6d0c76c4dff408de4044be85791896193f2435af7c280bc59312c3fb9649c/node_modules/safe-stable-stringify/index.js
20
+ // ../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/766ad107c42df28eeadaf0ac2f9089c8a9e200f6ba0b2b1a817b8d536cbdcc49/node_modules/safe-stable-stringify/index.js
21
21
  var require_safe_stable_stringify = __commonJS({
22
- "../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/88b6d0c76c4dff408de4044be85791896193f2435af7c280bc59312c3fb9649c/node_modules/safe-stable-stringify/index.js"(exports, module) {
22
+ "../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/766ad107c42df28eeadaf0ac2f9089c8a9e200f6ba0b2b1a817b8d536cbdcc49/node_modules/safe-stable-stringify/index.js"(exports, module) {
23
23
  "use strict";
24
24
  var { hasOwnProperty } = Object.prototype;
25
25
  var stringify = configure2();
@@ -758,6 +758,19 @@ async function parseErrorBody(res) {
758
758
  }
759
759
  return classifyHttpError(res.status, errObj, message2);
760
760
  }
761
+ function providerVerdict(status, errObj, message2) {
762
+ if (errObj.provider === void 0 && errObj.code !== "provider_error") return null;
763
+ return {
764
+ kind: "provider",
765
+ status,
766
+ provider: errObj.provider,
767
+ code: errObj.code ?? "provider_error",
768
+ message: message2,
769
+ // A 4xx is terminal unless the backend says otherwise — the opposite
770
+ // default from the 502 branch, where the status itself means "try again".
771
+ retryable: errObj.retryable ?? false
772
+ };
773
+ }
761
774
  function classifyHttpError(status, errObj, message2) {
762
775
  if (errObj.code === CONTENT_POLICY_CODE) {
763
776
  return { kind: "content_policy", status, provider: errObj.provider, message: message2 };
@@ -766,7 +779,7 @@ function classifyHttpError(status, errObj, message2) {
766
779
  return { kind: "unauthorized", status, message: message2 };
767
780
  }
768
781
  if (status === 400 || status === 422) {
769
- return { kind: "validation", status, message: message2, details: errObj.details };
782
+ return providerVerdict(status, errObj, message2) ?? { kind: "validation", status, message: message2, details: errObj.details };
770
783
  }
771
784
  if (status === 502 || status === 504) {
772
785
  if (errObj.code === "provider_timeout" || status === 504) {
@@ -1098,7 +1111,7 @@ function resolveAdaptFormats(params) {
1098
1111
  return params.formats ?? [];
1099
1112
  }
1100
1113
 
1101
- // ../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/88b6d0c76c4dff408de4044be85791896193f2435af7c280bc59312c3fb9649c/node_modules/safe-stable-stringify/esm/wrapper.js
1114
+ // ../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/766ad107c42df28eeadaf0ac2f9089c8a9e200f6ba0b2b1a817b8d536cbdcc49/node_modules/safe-stable-stringify/esm/wrapper.js
1102
1115
  var import__ = __toESM(require_safe_stable_stringify(), 1);
1103
1116
  var configure = import__.default.configure;
1104
1117
  var wrapper_default = import__.default;
@@ -1139,6 +1152,21 @@ function normalizeForCanonical(value) {
1139
1152
  return void 0;
1140
1153
  }
1141
1154
 
1155
+ // ../canvas-contract/src/castName.ts
1156
+ function withoutCastName(text, names) {
1157
+ let out = text;
1158
+ for (const name of names) {
1159
+ const token = name.trim();
1160
+ if (token.length < 3) continue;
1161
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}])${escapeForPattern(token)}(?![\\p{L}\\p{N}])`, "giu");
1162
+ out = out.replace(pattern, "the person in the reference");
1163
+ }
1164
+ return out;
1165
+ }
1166
+ function escapeForPattern(value) {
1167
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1168
+ }
1169
+
1142
1170
  // ../canvas-contract/src/clipWeight.ts
1143
1171
  var MB_PER_SECOND_720P = 2.13;
1144
1172
  var RESOLUTION_WEIGHT = {
@@ -6237,7 +6265,7 @@ function resolveStrategy(params) {
6237
6265
  case "text": {
6238
6266
  const p = safePathname(params.url);
6239
6267
  const isRawText = RAW_TEXT_EXTENSIONS.some((ext) => p.endsWith(ext));
6240
- return isRawText ? "direct_fetch" : "handinger";
6268
+ return isRawText ? "direct_fetch" : "firecrawl";
6241
6269
  }
6242
6270
  default: {
6243
6271
  params.expect;
@@ -6254,10 +6282,10 @@ function safePathname(rawUrl) {
6254
6282
  }
6255
6283
  var ingestNode = defineNode({
6256
6284
  id: "ingest",
6257
- version: "1.2.0",
6285
+ version: "1.3.0",
6258
6286
  category: "io",
6259
6287
  location: "local",
6260
- summary: "Ingest an external URL or a local file into the asset store. Declare the kind you expect (image/video/audio/text/json/font); the node picks the strategy. For source=url: yt-dlp for video/audio (YouTube/TikTok/Vimeo/etc. and direct file URLs), Handinger for HTML/PDF pages \u2192 markdown, direct HTTP fetch for binary URLs (images, fonts) and raw .txt/.md. For source=path: read from the local filesystem and upload to R2.",
6288
+ summary: "Ingest an external URL or a local file into the asset store. Declare the kind you expect (image/video/audio/text/json/font); the node picks the strategy. For source=url: yt-dlp for video/audio (YouTube/TikTok/Vimeo/etc. and direct file URLs), Firecrawl for HTML/PDF pages \u2192 markdown, direct HTTP fetch for binary URLs (images, fonts) and raw .txt/.md. For source=path: read from the local filesystem and upload to R2.",
6261
6289
  when_to_use: 'Use as the on-ramp for any external asset a canvas needs. `expect` makes the output port statically typed so downstream wiring is checked before any byte is fetched. Pick `source: "url"` for portable canvas; pick `source: "path"` for local files (canvas is not portable across machines without the file). Compose with other nodes for richer pipelines (e.g. `ingest expect=video` \u2192 `video_transcribe`).',
6262
6290
  inputs: IngestInputs,
6263
6291
  params: IngestParams,
@@ -6284,8 +6312,8 @@ function runStrategy(strategy, params, ctx) {
6284
6312
  switch (strategy) {
6285
6313
  case "direct_fetch":
6286
6314
  return execDirectFetch(params, ctx);
6287
- case "handinger":
6288
- return execHandinger(params, ctx);
6315
+ case "firecrawl":
6316
+ return execFirecrawl(params, ctx);
6289
6317
  case "yt_dlp":
6290
6318
  return execYtDlp(params, ctx);
6291
6319
  case "local_file":
@@ -6338,11 +6366,11 @@ async function ingestImageUrl(url, ctx) {
6338
6366
  ctx
6339
6367
  });
6340
6368
  }
6341
- async function execHandinger(params, ctx) {
6369
+ async function execFirecrawl(params, ctx) {
6342
6370
  const result = await callBackendExec({
6343
6371
  nodeType: "ingest",
6344
6372
  nodeVersion: ingestNode.version,
6345
- params: { strategy: "handinger", url: params.url },
6373
+ params: { strategy: "firecrawl", url: params.url },
6346
6374
  inputs: {},
6347
6375
  outputKinds: { asset: "text" },
6348
6376
  ctx
@@ -9570,6 +9598,7 @@ export {
9570
9598
  describeFailureReason,
9571
9599
  AD_FORMAT_PLATFORMS,
9572
9600
  platformFormats,
9601
+ withoutCastName,
9573
9602
  frameRealismDirection,
9574
9603
  SEEDANCE_DURATIONS,
9575
9604
  ELEVENLABS_MAX_MUSIC_LENGTH_MS,
@@ -9633,4 +9662,4 @@ export {
9633
9662
  defaultRegistry,
9634
9663
  createEngineFromEnv
9635
9664
  };
9636
- //# sourceMappingURL=chunk-ZNN6ZHDS.js.map
9665
+ //# sourceMappingURL=chunk-3TEVIAAV.js.map