Skip to content

feat(cms): fill a site's editorial brand context from its own blocks - #6341

Open
aka-sacci-ccr wants to merge 4 commits into
mainfrom
blogpost-generation-cms
Open

feat(cms): fill a site's editorial brand context from its own blocks#6341
aka-sacci-ccr wants to merge 4 commits into
mainfrom
blogpost-generation-cms

Conversation

@aka-sacci-ccr

@aka-sacci-ccr aka-sacci-ccr commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What

Adds an Autonomous content collection to the Content tab (Blog group), holding the brand context every generated blogpost will be written against: tone of voice, generation dos and don'ts, audience, values, blog categories, competitors.

Persists to the site's own .deco/blocks/blog-manager-brand.json as plain JSON — no new table. Same path and field names Spire writes, so a site it already set up opens here populated.

BLOG_BRAND_EXTRACT fills the empty fields by reading the site's own CMS blocks.

No scraper, no new credential

The first draft called Firecrawl. That was wrong: FIRECRAWL_API_KEY is an instance-wide env var, so someone on the hosted Studio can neither enable it nor bring their own key, and the cost lands on a shared quota. Reading a URL server-side would also have needed an SSRF guard we don't have (httpUrlSchema only rejects non-http schemes).

The blocks are already in the browser's decofile, so the tool takes them as input and only calls the model. Inference runs on the org's own smart tier via resolveTier — same path as suggest-commit-message and judge-requires-review — so it bills to the org, and the org picks the model.

Two things the real data forced

Tested against a real storefront's 1969 blocks:

  • Serialized block size is a bad proxy for prose. One institutional page was 15KB of JSON carrying two sentences — the rest asset URLs, signed video links and device matchers. Ranking 1018 pages by size surfaced product-listing stubs and buried the institutional pages that hold the brand's values. extractBlockProse walks a block and keeps only prop: phrase lines (a phrase = contains a space), deduped, which separates alt: "92% de funcionárias" from site/sections/Layout/Flex.tsx and 20px without a prop allowlist.
  • That site has 1018 pages and zero posts, so "read the existing posts" cannot be the only path. Tiers degrade: posts → categories → pages, and the prompt says so instead of treating a blogless site as the exception.

The prompt also encodes traps found in that data: internal asset annotations ([LP … ] [carrossel …]) are not copy; shipping/return/payment fine print is not editorial voice; brand-specific vocabulary (a site calling the shopping bag mochila) is the most valuable thing to capture verbatim; and casing is part of the voice, including when it's inconsistent.

Won't clobber hand-written rules

Re-running the extract writes only into fields that are still empty, so it can never wipe dos/don'ts someone typed. The toast reports how many it filled, or that there was nothing to fill.

Testing

62 unit tests in blog-data.test.ts cover the prose walker (drops URLs/identifiers/dimensions, keeps phrases and prop names, dedupes exact repeats, preserves casing variants) and the evidence ranking (tier order, prose-density ordering, char budget, empty site, missing page keys).

bun test apps/api/src/tools/ reports 25 failures — pre-existing: the same 25 fail on a clean tree (verified via git stash -u), and each file passes in isolation, so it's cross-file test pollution unrelated to this change.

tsc --noEmit clean in both workspaces, knip clean, lint 0 errors, fmt applied.

Not in this PR

  • The extract has not been exercised end-to-end against a live model yet — org-fs mounts break the daemon's write route off-cluster, so local verification needs DISABLE_ORGFS_MOUNTS=1. Unrelated to this diff.
  • selfhost/examples/dev-hybrid/.env.example still ships STUDIO_SANDBOX_PREVIEW_URL_PATTERN set, which 502s every daemon call off-cluster. Out of scope here; worth its own fix.
  • No sub-navigation inside the new tab — one section today, so a tab bar with one item would be noise. Planner, ideas and drafts land under the same heading later.

🤖 Generated with Claude Code


Summary by cubic

Infers a site’s editorial brand context from its own CMS blocks and adds an “Autonomous content” collection to edit it, so generated posts match the site’s tone and guardrails. Previously there was no editor and rule fields were string arrays; now rules are objects with a name and markdown body.

  • Adds Autonomous content to Content with Planner/Ideas (placeholders) and a Library editor (Context/Formats) that saves to .deco/blocks/blog-manager-brand.json with a sticky rail (Basics, Generation rules, Guardrails, Extra). dos, avoid, values, and competitors now use { name, value }; the markdown editor disables attachments for these text-only fields.

  • The Library’s Fill action calls BLOG_BRAND_EXTRACT to read the site’s blocks and fill only empty fields; the UI shows reading/inferring/searching phases.

  • BLOG_BRAND_EXTRACT (category “Blog”) runs on the org’s smart tier; input capped to 60 blocks and 12k chars per block. Evidence sampler prioritizes posts > categories > pages, with a 60k total and 4k per block. Returns sources and searchedCompetitors.

  • Competitors are enriched via the org’s web_search tier only when none are named in blocks; failures or missing tier return [].

  • Tests cover rule normalization, prose extraction, evidence ranking, budgets, and empty/missing cases.

  • Rollout

    • No DB/env changes. Backward compatible: legacy string[] rules load and save in the new shape; no migration required. Extraction is idempotent on non-empty fields.
    • Billing: brand inference bills the org’s smart tier; competitor enrichment uses web_search when configured.

Written for commit 22104fe. Summary will update on new commits.

Review in cubic

decobot and others added 4 commits August 20, 2026 17:54
Adds an "Autonomous content" collection to the Content tab, holding the brand
context every generated blogpost will be written against: tone of voice,
generation dos and don'ts, audience, values, blog categories, competitors.

Persists to the site's own `.deco/blocks/blog-manager-brand.json` as plain
JSON — no new table. Same path and field names Spire writes, so a site it
already set up opens here populated.

`BLOG_BRAND_EXTRACT` fills the empty fields by reading the site's own CMS
blocks. No scraper and no new credential: the blocks are already in the
browser's decofile, and the inference runs on the org's own `smart` tier via
`resolveTier`, so it bills to the org rather than to an instance-wide key.

Two things the real data forced:

- Serialized block size is a bad proxy for prose. Farm Rio's "Sobre Farm" page
  is 15KB of JSON carrying two sentences, and ranking its 1018 pages by size
  surfaced product-listing stubs while burying the institutional pages that
  hold the brand's values. `extractBlockProse` walks a block and keeps only
  `prop: phrase` lines, deduped.
- That site has 1018 pages and zero posts, so "read the existing posts" cannot
  be the only path. The tiers degrade: posts, then categories, then pages.

Re-running the extract only writes into fields that are still empty, so it can
never wipe dos/don'ts someone wrote by hand.

Testing: 62 unit tests in `blog-data.test.ts` cover the prose walker and the
evidence ranking (tier order, prose-density ordering, char budget, empty site,
missing page keys). The `25` failures in `bun test apps/api/src/tools/` are
pre-existing — same count on a clean tree, and each file passes in isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The brand context was ten fields in one column, four of them `string[]`. That
shape didn't hold up:

- A rule worth writing down doesn't fit one line. "Never print prices in a text
  block, use ProductCard" is a short name plus an explanation, and both were
  fighting over the same 36px input.
- A competitor's name alone says nothing about why it matters or how the brand
  differs from it — which is exactly what a generated post needs to know.
- Identity data, generation instructions and guardrails are read at different
  moments by different people, with no hierarchy separating them.

So `dos`, `avoid`, `values` and `competitors` become `Array<{name, value}>`,
where `value` renders in the markdown editor already used by the task dialog.
`categories` stays `string[]` — it's a taxonomy, not a rule, so it has no body.

No migration. `normalizeBrandRules` reads both shapes: a legacy flat string
becomes the rule's name with an empty body, so a block Spire wrote opens intact
and picks up the new shape on its next save.

Four tabs — Basics, Generation rules, Guardrails, Extra context. The "read this
site's content" card stays outside them, since it fills fields across all four.

`MarkdownEditor` reads `defaultValue` only on mount, so an extract that filled a
rule body would have left the old text on screen. An `editorRevision` counter
keys the editors and remounts them onto the new values.

Competitors now come from web search, because a site's own blocks structurally
cannot answer that one — a brand doesn't name rivals in its own copy. It runs
only when the blocks named none, on the org's own `web_search` tier via
`tryResolveTier`, and returns `[]` when the org has no such tier, when the
search finds nothing, or on any error: this enriches the result and must never
be what makes the extract fail. Scope is deliberately competitors-only; letting
search rewrite `tone` or `values` would trade the brand's own prose for a third
party's summary, which the prompt already forbids.

No import from the chat harness: `mode: "quick"` of the research hook reduces to
a call against the search-capable model, so this does that directly rather than
inventing a `taskId`/`toolCallId` for a durable job it doesn't need.

Testing: 6 new unit tests for `normalizeBrandRules` (legacy string, well-formed
rule, half-migrated mixed list, body-only rule, entries with no text, non-array).
217 pass across the content suite. `tsc` clean in both workspaces, `knip` clean,
`lint` 0 errors. Also drops `dosLabel`/`dontsLabel`, orphaned by the tab titles.

Not yet exercised against a live model: the editor remount, and whether this org
has a `web_search` tier at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nests the three screens behind the single "Autonomous content" collection row
instead of promoting them to siblings of Posts and Authors in the Content
sidebar. Planner and Ideas ship as placeholders that say what will land there;
the inner rail is what makes room for them without touching navigation again.

Library keeps the brand context, now under Content-OS-style underlined tabs
(Context / Formats) with a fixed four-item rail: basics, generation rules,
guardrails, extra context. The rail is sticky, so it stays put while a long
guardrail list scrolls.

The rule lists were a column of stacked markdown editors, which is unreadable
past two rules. Now a row shows the rule's name and clicking it opens that
rule's body, one at a time. Deleting a rule closes the editor when the indices
below it shift, so the wrong rule can't end up open.

`MarkdownEditor` grows an `attachments` prop, off for these fields: a brand
guardrail is text, and the picker plus the paste/drop upload handlers were
offering an image flow that has no meaning here.

The extract button says "Fill" rather than describing its mechanism, and while
it runs a status line names the step: reading the site, inferring the voice,
searching for competitors. Those are the pipeline's real phases but timed on
the client — the tool is one round trip, so the client cannot know the server's
step. That's also why there's no progress bar: the design system's `Progress` is
determinate, and any percentage here would be invented.

Both prompts now pin the output language to the site's own. A Portuguese site
was getting an English profile, which is unusable twice over: the people who
maintain it work in that language, and the model that later reads it copies the
language it sees. Fixed one instance of the same bug inside the prompt, where an
English example illustrated Portuguese output.

Also translates the pt-BR entries left in English (library, planner) and drops
the `useT` briefly added to `content-browser`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebasing onto main landed this branch on a tree where `use-blog-mutations.ts`
no longer exists — #6271 deleted it after blog writes started 404'ing, folding
the key-to-file mapping into the server. Git replayed the commits cleanly
because the import target was simply absent rather than conflicting, so only
`tsc` caught it.

`useSaveBlock` is the replacement and no longer takes `packagePath`; it resolves
that itself. The `blog-manager-brand` key has no slashes, which is the only
reason `use-blog-mutations` existed separately, so there is nothing else to port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aka-sacci-ccr
aka-sacci-ccr force-pushed the blogpost-generation-cms branch from a153839 to 22104fe Compare August 20, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant