@bettercms-ai/mcp 0.59.1 → 0.60.0

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/dist/index.js CHANGED
@@ -2268,15 +2268,19 @@ var uiObject = z.object({
2268
2268
  collapsed: z.boolean().optional().describe("start this panel collapsed. Use it for long optional sections."),
2269
2269
  preview: z.object({
2270
2270
  title: z.string().min(1).optional().describe("CHILD KEY whose value titles a collapsed row"),
2271
+ subtitle: z.string().min(1).optional().describe("CHILD KEY whose value is the row's second line (e.g. 'level' \u2192 'Heading \xB7 h2')"),
2271
2272
  media: z.string().min(1).optional().describe("CHILD KEY whose value is the row's thumbnail (image/file/url all work)")
2272
2273
  }).optional().describe(
2273
- "how ONE ITEM summarises itself when collapsed. Both slots name a CHILD KEY of THIS field/prop \u2014 not a value, not a dotted path. Without it a repeater of testimonials reads 'Item 1, Item 2, Item 3'; with {title:'author', media:'avatar'} it reads the names with their faces."
2274
+ "how ONE ITEM summarises itself when collapsed. Every slot names a CHILD KEY of THIS field/prop \u2014 not a value, not a dotted path. Without it a repeater of testimonials reads 'Item 1, Item 2, Item 3'; with {title:'author', media:'avatar'} it reads the names with their faces."
2274
2275
  ),
2275
2276
  layout: z.enum(["list", "grid"]).optional().describe(
2276
2277
  "how the ITEMS are arranged. Omit (\u2261 'list') for rows of text; 'grid' for items whose thumbnail is the thing you scan \u2014 a gallery, a logo wall, a team. Same placement rule as `preview`."
2277
2278
  ),
2278
2279
  reorderable: z.boolean().optional().describe(
2279
2280
  "omit (\u2261 true) unless the order is PART OF THE MEANING. `false` LOCKS the list \u2014 a 'three steps' band that must stay three steps in that order, a nav whose order is semantic, a timeline. Valid only where there is a list to lock."
2281
+ ),
2282
+ control: z.enum(["segmented", "slider"]).optional().describe(
2283
+ "editor control for a LEAF. Omit for the default. 'segmented' on a select with a few short options (alignment, theme, size); 'slider' on a number that has config.min < config.max (opacity, columns). Anything else is refused with the rule named."
2280
2284
  )
2281
2285
  });
2282
2286
  var fieldShape = {
@@ -2350,6 +2354,7 @@ var fieldsetsArg = z.array(
2350
2354
  "the model's editor cards: [{id, name, description?}], at most 30, ids unique, order = array index. `description` is one line under the card title. A field joins one with `fieldsetId`. Editor-only: never changes what is stored or delivered."
2351
2355
  );
2352
2356
  var RICH_TEXT_NOTE = "\u{1F534} A field of type 'text' is created as RICH TEXT unless you send `richText: false` on it. Send `richText: false` for every value that is not prose: a name used as a title, a URL, slug, id, SKU, email, phone, CSS class or icon name.";
2357
+ var PAGES_NOT_COLLECTIONS_NOTE = "A collection is only for items one template route repeats (/blog/:slug: set urlPattern). A single page's copy (home, about, contact, pricing, legal) is page fields in sections (add_page_field / set_page_content), never a one-entry collection.";
2353
2358
  function flatFieldKeys(fields) {
2354
2359
  const out = [];
2355
2360
  const childrenOf = (f) => {
@@ -2431,11 +2436,11 @@ function toField(f) {
2431
2436
  ...f.config ? { config: f.config } : {}
2432
2437
  };
2433
2438
  }
2434
- var BRAND_KIT_NOTE = "`brandKit` is the project's brand kit: colors, typography, radius and any typeScale/spacing/elevation/motion/graphics. It is edited in the BetterCMS dashboard AND by set_brand_assets and set_brand_graphics below; every write is a version a person can restore. ASSETS: point `mark`/`favicon` at a media asset OF THIS PROJECT by id (another project's id is refused); null clears one; a slot a person chose needs `replace: true`. GRAPHICS: gradients are STRUCTURED \u2014 `kind`, optional `angle`, 2-8 stops naming kit colour keys (`colors.*` or an `extras` key) \u2014 never a CSS string and never a hex; a colour the kit lacks is added to `extras` first. CONSUME, DO NOT COPY: on a hosted page the kit is `--brand-color-<key>`, `--brand-shadow-<key>` and `--brand-gradient-<key>`; style sections with their `style` tokens (`nav.backgroundGradient` and `footer.backgroundGradient` take a gradient key) rather than copying its values into props. Never invent brand facts: read them here.";
2439
+ var BRAND_KIT_NOTE = "`brandKit` (colors, typography, radius and any typeScale/spacing/elevation/motion/graphics) is edited in the BetterCMS dashboard AND by set_brand_assets and set_brand_graphics; every write is a version a person can restore. ASSETS: a media asset OF THIS PROJECT by id; null clears one; a slot a person chose needs `replace: true`. GRAPHICS: `kind`, optional `angle`, 2-8 stops naming `colors.*` or `extras` keys \u2014 never a CSS string and never a hex. CONSUME, DO NOT COPY: a hosted page exposes `--brand-color-<key>`, `--brand-shadow-<key>` and `--brand-gradient-<key>`; style sections with their `style` tokens (`nav.backgroundGradient` and `footer.backgroundGradient` take a gradient key), never copied values. Never invent brand facts: read them from get_project.";
2435
2440
  var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model. When sent, the update applies only if the model is still at that version; a 409 means someone changed it since \u2014 read it again and re-apply, never retry blindly. Omitted, the last write wins.";
2436
2441
  var ENTRY_META_NOTE = "SEO is native: set this entry's metaTitle, metaDescription, noindex, canonical, og, twitter, schemaType or schema in `meta` (never as model fields). `meta` MERGES into what is stored: a key you leave out is kept, null or an empty string clears it.";
2437
2442
  var PAGE_HEAD_NOTE = "noindex, canonical, og and twitter set the page's head extras; each MERGES into what is stored (a key you leave out is kept, null or an empty string clears it).";
2438
- var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep. Icons are lucide icon names (a-z, 0-9, dashes); null clears one. SYSTEM FOLDERS: "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist and can never be moved or deleted (400 NAV_SYSTEM_FOLDER); rename_folder and set_icon do work on them. TYPE PURITY (400 NAV_TYPE_MISMATCH): Other pages holds only pages at any depth, Shared only collections, and Settings neither \u2014 globals are not sidebar items. ACCESS: changing the structure needs Admin or Developer, the same as editing the schema; reading needs only content access. A 403 SCHEMA_ACCESS_REQUIRED means this connection is neither: stop and tell the user, do not retry. Pins sit above the folders, in their own order, and may be pages or collections. In a document a pin is folderId "home:root", a virtual id with no folder record (a folder record with that id is 400 NAV_SYSTEM_FOLDER). set_content_structure never refuses a kind mismatch: it returns `warnings` and `kindMismatches` (the single writes do \u2014 see `kind`). PAGE-BOUND MODELS: a page with fields has a model bound to it. That model is NOT a collection: it is never listed or filed, and the writes refuse it with 400 NAV_PAGE_BOUND_MODEL naming its page. Organise the PAGE instead, by its page id. ';
2443
+ var STRUCTURE_NOTE = 'ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state; URL folders (page folders) are set in the dashboard. Folders nest at most 4 levels deep. Icons are lucide names (a-z, 0-9, dashes); null clears one. SYSTEM FOLDERS "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist and can never be moved or deleted (400 NAV_SYSTEM_FOLDER); rename_folder and set_icon work on them. TYPE PURITY (400 NAV_TYPE_MISMATCH): Other pages holds only pages at any depth, Shared only collections, Settings neither \u2014 globals are not sidebar items. ACCESS: changing the structure needs Admin or Developer, reading only content access; a 403 SCHEMA_ACCESS_REQUIRED means neither: stop and tell the user, do not retry. PINS sit above the folders in their own order, pages or collections; in a document a pin is folderId "home:root", a virtual id with no folder record (a folder record with that id is 400 NAV_SYSTEM_FOLDER). set_content_structure never refuses a kind mismatch: it returns `warnings` and `kindMismatches` (the single writes do \u2014 see `kind`). PAGE-BOUND MODELS: a page with fields has a bound model that is NOT a collection: never listed or filed, and the writes refuse it with 400 NAV_PAGE_BOUND_MODEL naming its page. Organise the PAGE instead, by its page id. ';
2439
2444
  var STRUCTURE_FENCE = "ORGANISATION ONLY: Content sidebar folders never change a URL, slug, content or publish state, and nothing is rebuilt or delivered; URL folders (page folders) are set in the dashboard. Read the resource bettercms://playbook/structure before you organise anything: it holds the rules this refuses on \u2014 system folders, type purity, the Admin/Developer access check, root pins and page-bound models. A resource is never truncated; a long description is.";
2440
2445
  function structureResult(d) {
2441
2446
  const message = d?.message;
@@ -2443,18 +2448,18 @@ function structureResult(d) {
2443
2448
  }
2444
2449
  var GIT_TEXT = {
2445
2450
  list_github_repos: "Every GitHub repository this workspace's connected GitHub App installations can reach, grouped by account. Use it to find the repository to import: each ACCOUNT carries the `installationId` that import_github_repo and fork_github_repo take, and each repository under it carries `owner`, `repo` and `defaultBranch`. An empty `accounts` list means nobody has connected GitHub to this workspace yet \u2014 tell the user to connect it from the dashboard (Project \u2192 Hosting \u2192 Connect GitHub) and call this again.",
2446
- import_github_repo: "Connect an existing GitHub repository to the connected project \u2014 the agent's equivalent of the dashboard's Import from GitHub, and the thing to do BEFORE writing any code for a user who already has a repo. It wires the project API key and variables into the repository, records the connection, assigns the site handle, imports the repo's bcms-content.json as content models when it has one, and queues the first build. Pass `installationId`, `owner` and `repo` from list_github_repos; `branch` defaults to the repository's own default branch and becomes the branch every deploy builds from. It only works on a repository one of this workspace's installations can already reach \u2014 for anyone else's repository (a public starter, a template, another account's site) use fork_github_repo instead. Read `warning` / `contentWarning` / `envWarning` back to the user verbatim when they come back (`envWarning` names the environment variables the repo documents in .env.example and the project lacks \u2014 the site renders empty or crashes until they are set) \u2014 they are the cases where the repository connected perfectly and still cannot build, or will leave the Pages tab empty. Re-importing replaces the project's existing connection.",
2451
+ import_github_repo: "Connect an existing GitHub repository to the connected project (the dashboard's Import from GitHub) \u2014 do it BEFORE writing any code for a user who already has a repo. It wires the project API key and variables into the repository, records the connection, assigns the site handle, imports the repo's bcms-content.json as content models when it has one, and queues the first build. Pass `installationId`, `owner` and `repo` from list_github_repos; `branch` defaults to the repository's own default branch and becomes the branch every deploy builds from. It only works on a repository this workspace's installations can already reach \u2014 for anyone else's (a public starter, a template, another account's site) use fork_github_repo. Read `warning` / `contentWarning` / `envWarning` back to the user verbatim when they come back (`envWarning` names the environment variables the repo documents in .env.example and the project lacks \u2014 the site renders empty or crashes until they are set) \u2014 the repository connected but cannot build, or will leave the Pages tab empty. Re-importing replaces the project's existing connection.",
2447
2452
  fork_github_repo: "Fork ANY GitHub repository into the user's own account and import it in one step \u2014 how a user starts from a repository that is not theirs (a public starter, a template, another account's site). BetterCMS cannot wire a repository it has no installation on, so the copy has to live on their account first; this makes that copy, keeps the upstream link, and then connects it exactly as import_github_repo does. `installationId` names the DESTINATION account (from list_github_repos); `owner` and `repo` name the SOURCE, anywhere on GitHub. `name` renames the fork; `connect: false` forks without connecting. Forking is asynchronous: a 202 means the fork exists but GitHub is still copying it, and the answer tells you to call import_github_repo with the fork's owner and repo a moment later \u2014 never fork a second time.",
2448
2453
  list_github_branches: "The connected repository's branches, plus `buildBranch` \u2014 the one the provisioned Action builds from. A commit on any other branch never reaches the live site, so check this before you push.",
2449
2454
  list_github_commits: "The connected repository's commit history, newest first: sha, message, author, date and url. Defaults to the build branch; pass `branch` for another, `path` to narrow it to one file or directory, and `limit` (1-100, default 20). Read the head sha here before a push you want to be safe, and hand it back as push_to_github's `expectedHeadSha`.",
2450
- push_to_github: "Commit files to the connected repository \u2014 a real git commit, written server-side with the GitHub App's own credentials, so you need no clone, no remote and no token of your own. Send `files` ([{path, content, encoding}]) and/or `deletePaths`, plus a `message`; both go into ONE commit, so a rename is atomic. It targets the BUILD branch by default, where the push starts the rebuild that goes live \u2014 poll get_deploy_status after it, and read the response's `note`, which says whether this push deploys. Pass `branch` to push somewhere else (a branch that does not exist yet is created from the build branch, or from `baseBranch`) and then open a pull request with create_github_pull_request. Pass `expectedHeadSha` from list_github_commits \u2014 a full sha or a prefix of one \u2014 to be refused with a 409 rather than silently overwrite a branch someone else moved while you worked. Limits, enforced BEFORE anything is written: 200 paths and 10 MB per commit, and `.git`, `.github/workflows`, `.env` and `.npmrc` are never writable \u2014 a refused patch writes nothing at all and names every offending path.",
2455
+ push_to_github: "Commit files to the connected repository \u2014 a real git commit written server-side by the GitHub App: no clone, remote or token of your own. Send `files` ([{path, content, encoding}]) and/or `deletePaths`, plus a `message`; both go into ONE commit, so a rename is atomic. It targets the BUILD branch by default, where the push starts the rebuild that goes live \u2014 poll get_deploy_status after it, and read the response's `note`, which says whether this push deploys. Pass `branch` to push somewhere else (a branch that does not exist yet is created from the build branch, or from `baseBranch`) and then open a pull request with create_github_pull_request. Pass `expectedHeadSha` from list_github_commits \u2014 a full sha or a prefix of one \u2014 to be refused with a 409 rather than silently overwrite a branch someone else moved while you worked. Limits, enforced BEFORE anything is written: 200 paths and 10 MB per commit, and `.git`, `.github/workflows`, `.env` and `.npmrc` are never writable \u2014 a refused patch writes nothing at all and names every offending path.",
2451
2456
  create_github_pull_request: "Open a pull request on the connected repository. `head` is the branch you pushed; `base` defaults to the build branch, so merging it is what ships the change. Use this instead of pushing straight to the build branch whenever a human should review first, or when that branch is protected. Returns the number and the url to hand the user.",
2452
2457
  list_github_pull_requests: "Pull requests on the connected repository: number, title, state, whether it merged, draft, head, base and url. `state` is 'open' (the default), 'closed' or 'all'. Poll it to find out whether the pull request you opened has landed."
2453
2458
  };
2454
2459
  var HOSTING_TEXT = {
2455
2460
  list_env_vars: "The project's environment variables by NAME and scope (never a value), and `missing`: the ones the repository's .env.example declares that the project does not set. A site missing them builds and serves, then renders empty or crashes in the browser. Resolve `missing` by running `npx @bettercms-ai/cli@latest env push` in the repository: it uploads those values from the user's own .env files straight to BetterCMS, so they never pass through this conversation. Never read, print or ask the user to paste a value. When the values do not exist locally because the site reads its content from another CMS, move that content into BetterCMS instead (the bettercms skill's external-content reference). BetterCMS's own BCMS_* identifiers are supplied to every build and never need setting.",
2456
2461
  list_hosting_connections: "The Vercel, Netlify and Cloudflare accounts connected to this workspace (id, provider, accountLabel, status; never a token), plus `connectUrl`, the dashboard page where a person connects one. Call it before deploy_to_host. If the provider the user wants has no connection with status `ok`, give the user `connectUrl` to connect it in their browser and stop until they have: you never handle OAuth or a host token yourself.",
2457
- deploy_to_host: "Deploy this project to the user's own Vercel or Netlify account through their connection. BetterCMS builds the site and the host serves that build, so there is no host CLI to run, no token to paste and no vercel.json rewrite to write; forms keep working because they post to the BetterCMS API. The first call creates the site on the host and makes it the project's host; a later call for the same provider redeploys that site (`redeployed: true`) instead of creating another. `connectionId` (from list_hosting_connections) defaults to the newest working one, `siteName` to the project's handle. A refusal carries a `code` and a `fixUrl` (not_connected, reconnect, pick_existing_site, grant_required, ADMIN_REQUIRED): each is fixed by the user at `fixUrl`, so relay it and stop. `deploymentQueued: false` means the project has no live build yet: deploy_project first, and that release reaches the host on its own. Never run the Vercel or Netlify CLI while a connection exists. Then poll get_host_deploy_status. Needs artifact:write, like deploy_project.",
2462
+ deploy_to_host: "Deploy this project to the user's own Vercel or Netlify account through their connection. BetterCMS builds the site and the host serves that build: no host CLI, no token to paste, no vercel.json rewrite; forms keep posting to the BetterCMS API. The first call creates the site on the host and makes it the project's host; a later call for the same provider redeploys that site (`redeployed: true`) instead of creating another. `connectionId` (from list_hosting_connections) defaults to the newest working one, `siteName` to the project's handle. A refusal carries a `code` and a `fixUrl` (not_connected, reconnect, pick_existing_site, grant_required, ADMIN_REQUIRED): each is fixed by the user at `fixUrl`, so relay it and stop. `deploymentQueued: false` means the project has no live build yet: deploy_project first, and that release reaches the host on its own. Never run the Vercel or Netlify CLI while a connection exists. Then poll get_host_deploy_status. Needs artifact:write, like deploy_project.",
2458
2463
  get_host_deploy_status: "The connected project's recent deployments to Vercel, Netlify or Cloudflare, newest first: state (queued|deploying|live|failed), providerUrl, error, sha and targetName. Poll it after deploy_to_host until the newest one is `live`, whose `providerUrl` is the live URL to hand the user, or `failed`, whose `error` you read back to them."
2459
2464
  };
2460
2465
  function ok(summary, data) {
@@ -2754,7 +2759,8 @@ function buildToolDefs(deps) {
2754
2759
  data: z.record(z.string(), z.unknown()).describe(
2755
2760
  "field values keyed by field key. A nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value, \u2026 }, repeatable: [ { childKey: value }, \u2026 ] } \u2014 nonRepeatable holds the fixed-block values, repeatable is the list of item objects (omit a zone you didn't define). A primitive 'array' (itemType) is a plain list. An 'image' value is an asset URL or asset id (from upload_asset) \u2014 the server resolves it to { id, url, name, altText }. Read get_page first to see each field's zones."
2756
2761
  ),
2757
- status: z.enum(["draft", "published"]).optional().describe("omit to leave status unchanged")
2762
+ status: z.enum(["draft", "published"]).optional().describe("omit to leave status unchanged"),
2763
+ replace: z.boolean().optional().describe("true replaces the whole entry data, removing omitted fields; default false merges by top-level key")
2758
2764
  });
2759
2765
  const getEntryInput = z.object({
2760
2766
  entryId: z.string().min(1).describe("content entry id")
@@ -3248,7 +3254,7 @@ ${d.summary.outline}` : "Content structure.", d);
3248
3254
  def(
3249
3255
  "suggest_content_structure",
3250
3256
  "Propose the standard Content structure",
3251
- `READ-ONLY: propose the standard Content sidebar structure for the connected project (${STRUCTURE_PLAYBOOK_URI}) and write NOTHING. Runs the platform's own implementation of the standard over the project's pages, collections, their references and the routes its build serves: the home page and each collection's list page pinned at the top, one folder per content family named for the detail type its route holds (Blog posts, Case studies, Jobs, Products with its taxonomies and an Attributes subfolder for its variant options), ordered by entry count (or the site navigation, when the caller supplies its link order), shared taxonomies in Shared, every remaining standalone and code-only page in Other pages, at most 2 levels deep. Page-bound models are never filed. Returns { doc, version, hasDocument, outline, rationale }. To apply it: show the user the outline, then call set_content_structure with this doc and this version (the If-Match; a 412 means someone changed the sidebar, so suggest again). When hasDocument is true the project already has a structure somebody made: never replace it without the user's yes; file only what is new with move_to_folder instead.`,
3257
+ `READ-ONLY: propose the standard Content sidebar structure for the connected project (${STRUCTURE_PLAYBOOK_URI}) and write NOTHING. Runs the standard over the project's pages, collections, references and built routes: the home page and each collection's list page pinned, one folder per content family named for its detail type (Blog posts, Case studies, Products with its taxonomies and an Attributes subfolder), ordered by entry count (or the caller's nav order), shared taxonomies in Shared, every remaining page in Other pages, at most 2 levels deep. Page-bound models are never filed. Returns { doc, version, hasDocument, outline, rationale }. To apply it: show the user the outline, then call set_content_structure with this doc and this version (the If-Match; a 412 means someone changed the sidebar, so suggest again). When hasDocument is true the project already has a structure somebody made: never replace it without the user's yes; file only what is new with move_to_folder instead.`,
3252
3258
  z.object({
3253
3259
  maxDepth: z.number().int().min(1).max(4).optional().describe("folder nesting to propose; default 2 (the standard), 4 is the hard limit")
3254
3260
  }).shape,
@@ -3321,7 +3327,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3321
3327
  def(
3322
3328
  "set_brand_assets",
3323
3329
  "Point the brand's logo or favicon at a media asset",
3324
- `Point the brand's logo (mark) or favicon at a media asset of this project. The asset must already be in this project's media library \u2014 upload it first if it is not. null clears a slot. A slot a PERSON chose is refused unless you pass replace: true, and the refusal says so. Every write is a brand version the dashboard can restore. ${BRAND_KIT_NOTE}`,
3330
+ `Point the brand's logo (mark) or favicon at a media asset of this project \u2014 upload it to this project's media library first if it is not there. A slot a PERSON chose is refused without replace: true, and the refusal says so. ${BRAND_KIT_NOTE}`,
3325
3331
  z.object({
3326
3332
  mark: z.string().min(1).nullable().optional().describe("media asset id for the logo; null clears it"),
3327
3333
  favicon: z.string().min(1).nullable().optional().describe("media asset id for the favicon; null clears it"),
@@ -3332,7 +3338,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3332
3338
  def(
3333
3339
  "set_brand_graphics",
3334
3340
  "Add, replace or remove the brand's gradients",
3335
- `Add, replace or remove the brand's gradients \u2014 the blobs and washes a site uses as backgrounds. They are TOKENS, not media: a gradient is a value, so it is stored as a kind, an angle and stops that name the kit's own colour keys, never as CSS and never as a hex. A stop naming a colour the kit does not have is refused; add it to extras first. Upsert matches BY KEY. ${BRAND_KIT_NOTE}`,
3341
+ `Add, replace or remove the brand's gradients \u2014 the blobs and washes a site uses as backgrounds. They are TOKENS, not media. A stop naming a colour the kit does not have is refused; add it to \`extras\` first. Upsert matches BY KEY. ${BRAND_KIT_NOTE}`,
3336
3342
  z.object({
3337
3343
  upsert: z.array(z.object({
3338
3344
  key: z.string().min(1).describe("token key: lowercase letters, digits and hyphens. Emitted as --brand-gradient-<key>"),
@@ -3541,7 +3547,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3541
3547
  def(
3542
3548
  "set_binding_mode",
3543
3549
  "Set how the site's bindings are resolved",
3544
- "Switch this project between the two binding resolvers, from the NEXT release on. `declaredBindings: true` makes the annotator trust the template's own data-bcms-field / data-bcms-props and never guess from rendered text \u2014 the durable state; `false` returns to text-matching, which works once (at import, when the CMS values equal the built copy) and breaks the first time anyone edits a value. Call it ONLY after every page's copy is declared in the template: undeclared fields stop being editable. The order is push \u2192 release \u2192 get_binding_report shows mode 'text-match' with 0 unmatched \u2192 set_binding_mode \u2192 release again \u2192 get_binding_report shows mode 'declared'. Flipping back is the same call. REQUIRES the artifact:write scope \u2014 the same authority that deploys the site \u2014 because this decides what every future release does to every page. On a workspace-wide connection pass `projectId`: the scope is there, but the human who authorized the connection must be able to publish THAT project or it is refused with 403 PROJECT_PUBLISH_DENIED. See section 13 of the bettercms://playbook/schema resource.",
3550
+ "Switch this project between the two binding resolvers, from the NEXT release on. `declaredBindings: true` makes the annotator trust the template's own data-bcms-field / data-bcms-props and never guess from rendered text \u2014 the durable state; `false` returns to text-matching, which works once (at import, when the CMS values equal the built copy) and breaks the first time anyone edits a value. Call it ONLY after every page's copy is declared in the template: undeclared fields stop being editable. The order is push \u2192 release \u2192 get_binding_report shows mode 'text-match' with 0 unmatched \u2192 set_binding_mode \u2192 release again \u2192 get_binding_report shows mode 'declared'. Flipping back is the same call. REQUIRES artifact:write (the authority that deploys the site): this decides what every future release does to every page. On a workspace-wide connection pass `projectId`: the scope is there, but the human who authorized the connection must be able to publish THAT project or it is refused with 403 PROJECT_PUBLISH_DENIED. Playbook section 13.",
3545
3551
  z.object({ declaredBindings: z.boolean().describe("true = trust the template's declared bindings; false = text-match (the default)") }).shape,
3546
3552
  async (c, a) => ok("Recorded the binding mode.", await data(c, "PATCH", `/management/projects/current/binding-mode`, { declaredBindings: a.declaredBindings }))
3547
3553
  ),
@@ -3566,10 +3572,10 @@ ${d.outline}` : "Proposed Content structure.", d);
3566
3572
  def(
3567
3573
  "submit_conversion_receipt",
3568
3574
  "Record what the conversion codemod could and could not do",
3569
- "Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert` wrote (`--receipt out.json`) for the SAME `briefDigest` get_conversion_brief { complete: true } returned: `{ briefDigest, receipt }`, where the receipt carries `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length, and each pending `reason` is one of the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026) \u2014 a path with no receipt row simply reads `not-declared`. \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: The response answers `complete` and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`. `action` is `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data` or `manual`; each row's `snippet` and `why` say what to change there. On a `declare-richtext` with `kind: document`, bind the ONE element wrapping every block of the Body, never the paragraph holding its first. Apply every fix, rerun, resubmit. `complete: true` is the only receipt that ends a conversion; do not report a site converted on less. It is a RECORD, not a release: it changes nothing about the site, and the meter picks it up on the next get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} SUBMIT THE `--forms` RECEIPT TOO, AS A SECOND CALL: A `npx @bettercms-ai/convert --forms` run writes a receipt whose `paths` are all zero and whose account is in a `forms` block. Write it to its own file (`--receipt forms-receipt.json`) and submit it under the same `briefDigest`: stored beside the binding receipt, never counted in coverage. Then read `forms.pending` and publish every form it names. \u{1F534} THE SITE'S DESIGN IS NOT YOURS TO CHANGE: run `npx @bettercms-ai/convert --verify-styles --base <commit before the conversion> --root .` and include its `styleEdits` in the receipt. A receipt listing any changed class, className or style is refused with 409 `style-edits` and every token \u2014 restore them; the CMS keeps inline markup exactly as written. Requires artifact:write, the same authority as set_binding_mode.",
3575
+ "Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` (`sourceComplete` is the receipt's own verdict; `liveChecks` gives the last release's `structureUnstamped` route count and `reportSha`) and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`, `action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; `snippet` and `why` say what to change there. On a `declare-richtext` with `kind: document`, bind the ONE element wrapping every block of the Body, never the paragraph holding its first. Apply every fix, rerun, resubmit: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} SUBMIT THE `--forms` RECEIPT TOO, AS A SECOND CALL: a `--forms` run's receipt has all-zero `paths` and a `forms` block; write it to its own file (`--receipt forms-receipt.json`) and submit it under the same `briefDigest` (stored beside the binding receipt, never counted in coverage), then publish every form its `forms.pending` names. \u{1F534} THE SITE'S DESIGN IS NOT YOURS TO CHANGE: run `npx @bettercms-ai/convert --verify-styles --base <commit before the conversion> --root .` and include its `styleEdits` in the receipt; one listing any changed class, className or style is refused with 409 `style-edits` naming every token \u2014 restore them. Requires artifact:write.",
3570
3576
  z.object({
3571
3577
  briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
3572
- receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
3578
+ receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim: `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }` plus `styleEdits`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length; each pending `reason` is the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026); a path with no row reads `not-declared`.")
3573
3579
  }).shape,
3574
3580
  async (c, a) => ok("Recorded the conversion receipt.", await data(c, "POST", `/management/projects/current/conversion-receipt`, { briefDigest: a.briefDigest, receipt: a.receipt }))
3575
3581
  ),
@@ -3664,7 +3670,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
3664
3670
  def(
3665
3671
  "deploy_project",
3666
3672
  "Deploy new source/build",
3667
- "Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`: SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git, and build output/caches (dist, build, .next, .astro, .cache) BEFORE creating the archive: a source deploy is reinstalled and built server-side, so those are never needed, and the upload has a hard size ceiling (~100 MB) enforced before the request reaches the server \u2014 an archive that includes node_modules is rejected in transit (a 413/502 with no server-side detail). Keep the archive to your own source files. Server-side stripping exists as a safety net, but it runs AFTER the upload and cannot rescue an over-limit body. For a LARGE archive (or if this returns a 413/502), use create_deploy_upload + deploy_from_upload instead \u2014 that path uploads straight to storage with no size ceiling. Returns `canvas.lane` \u2014 which live-preview lane the last release gave the editor (`bridge` / `draft-route` / `none`; see the playbook's section 11) \u2014 and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
3673
+ "Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`: SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git and build output/caches (dist, build, .next, .astro, .cache) from the archive: the source is reinstalled and built server-side, and the ~100 MB upload ceiling is enforced in transit (a 413/502 with no server-side detail) \u2014 server-side stripping runs AFTER the upload and cannot rescue an over-limit body. For a LARGE archive (or a 413/502) use create_deploy_upload + deploy_from_upload, which uploads straight to storage with no size ceiling. Returns `canvas.lane` (the editor's live-preview lane: `bridge` / `draft-route` / `none`; playbook section 11) and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
3668
3674
  z.object({ data: z.string().min(1).describe("base64 .tgz/.zip of the project"), mimeType: z.string().optional() }).shape,
3669
3675
  async (c, a) => ok("Deploy queued.", await raw(c, `/management/projects/deploy`, s(a.data), a.mimeType))
3670
3676
  ),
@@ -3803,14 +3809,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
3803
3809
  def(
3804
3810
  "get_binding_report",
3805
3811
  "Check what on the live site is editable",
3806
- "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), what was bound, and `unmatched` \u2014 each path with the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason` says which: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one and redeploying will not change it (verify that site by fetching it); `outdated` \u2014 the next release rewrites it. It certifies one thing: every non-empty field has SOME element carrying its path. It cannot see copy that was never modelled, so diff each route's visible text against its entry values yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for, or null when it cannot say. `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge`, `draft-route` or `none`, in which case get_next_steps carries the recipe (playbook section 11). `unaddressable` counts visible text on the live site that no field owns \u2014 the one thing `unmatched` structurally cannot see, because it only ever speaks about fields that already exist. EVERY release measures it server-side on every inspected page and the result names the routes, their countable characters and the buckets the unowned text sits in, biggest first. Anything over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11 has the loop. `skipReasons` says why pages were not inspected; `coverage.error` = `nothing-bound` means not one element of this build carries a binding, so the percentage beside it describes a site this build is not.",
3807
- z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in.") }).shape,
3812
+ "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; per slot this returns `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), what was bound, and `unmatched` \u2014 each path with its reason (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one, redeploy or not (verify it by fetching it); `outdated` \u2014 the next release rewrites it. It certifies only that every non-empty field has SOME element carrying its path; it cannot see copy that was never modelled, so diff each route's visible text against its entry values before calling a page done. `unaddressable` is that measure: visible text on the live site no field owns, measured server-side on every inspected page each release, by route and bucket, biggest first. Over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11. `builtRoutes` lists the routes the live build has HTML for (null = cannot say). `canvas.lane` is the editor's live-preview lane \u2014 `bridge`, `draft-route` or `none` (then get_next_steps carries the recipe). `skipReasons` says why pages were not inspected; `coverage.error` = `nothing-bound` means no element of this build carries a binding, so the percentage beside it describes a site this build is not.",
3813
+ z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in. The response's `sha` is the build the report describes; `refreshRequired` is true when that is not the build this slot serves.") }).shape,
3808
3814
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
3809
3815
  ),
3810
3816
  def(
3811
3817
  "get_conversion_brief",
3812
3818
  "Get the brief for making this site's bindings durable",
3813
- "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, and every bindable field path with its `label`, `kind`, the value the CMS holds now (`current`) and the copy the repo renders today (`original`, the field's defaultValue) \u2014 plus the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_page_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane` says how to get the source ('git-connected' = pull_project_source returns a repo; 'archive' = a tarball). The full recipe is section 13 of the bettercms://playbook/schema resource; get_binding_report is the receipt that says you finished. Pass `complete: true` when you are about to run the CODEMOD (`npx @bettercms-ai/convert`): that returns the brief UNCAPPED and paged \u2014 nothing truncated, no page omitted \u2014 and pins the full path list under a `briefDigest`, which is the list the coverage meter in get_binding_report measures the build against. Follow `cursor` until it stops coming back; a 409 `BRIEF_CHANGED` means the brief was re-derived while you paged, so start again. The complete brief also carries `forms` \u2014 every form of this project with its id, status, fields and submitUrl \u2014 which is what `npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json` wires into the repository's own <form> elements; run it after the binding pass, read its `forms.pending`, and publish every form its `forms.notes` names in the Forms tab (a draft form rejects every submission with a 403).",
3819
+ "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_page_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane`: 'git-connected' = pull_project_source returns a repo; 'archive' = a tarball. Full recipe: section 13 of bettercms://playbook/schema; get_binding_report is the receipt that says you finished. Pass `complete: true` before running the CODEMOD (`npx @bettercms-ai/convert`): the brief UNCAPPED and paged, its full path list pinned under a `briefDigest` \u2014 what the coverage meter measures the build against. The complete brief also carries `forms` (id, status, fields, submitUrl), which `npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json` wires into the repository's own <form> elements after the binding pass; read its `forms.pending` and publish every form its `forms.notes` names in the Forms tab (a draft form rejects every submission with a 403).",
3814
3820
  z.object({
3815
3821
  complete: z.boolean().optional().describe("true = the COMPLETE brief for a codemod: nothing truncated, no page omitted, paged 50 pages at a time. Page 1 pins the path list the coverage meter measures against."),
3816
3822
  cursor: z.string().optional().describe("The `cursor` from the previous page. Implies complete. A 409 BRIEF_CHANGED means the brief was re-derived mid-pagination \u2014 start again with no cursor.")
@@ -3820,21 +3826,21 @@ ${res.warnings.join("\n")}` : summary, res.data);
3820
3826
  def(
3821
3827
  "get_conversion_plan",
3822
3828
  "Get the approved conversion to apply",
3823
- "The APPROVED conversion a human reviewed in the BetterCMS dashboard: the exact new contents of each template file, already checked against this project's real field paths. Apply it instead of writing the bindings by hand. Check out `baseHeadOid` (the exact commit it was written against \u2014 a plan applied to a different base is a different change), branch from there, write each file's `content` verbatim (whole file, no merge, no reformatting), then push or deploy however this project ships. `stale.head` / `stale.brief` say the repository or the CMS moved since it was approved: stop and ask for a fresh proposal rather than applying it anyway. `receipt.paths.pending` lists every field the codemod could NOT bind, each with a reason (`DIALECT_UNSUPPORTED`, `PROP_TARGET_NOT_FOUND`, `REPEATER_FIXED_LENGTH`, \u2026) \u2014 apply the plan first, then bind those by hand. Finish the loop the same way as a hand conversion \u2014 get_binding_report until `unmatched` is empty, then set_binding_mode { declaredBindings: true } and release once more. 404 with `code: \"no-approved-plan\"` means nobody has approved one: use get_conversion_brief and do the conversion yourself. Requires artifact:write; on a workspace-wide connection pass `projectId`.",
3829
+ "The APPROVED conversion a human reviewed in the BetterCMS dashboard: the exact new contents of each template file, already checked against this project's real field paths. Apply it instead of writing the bindings by hand. Check out `baseHeadOid` (the exact commit it was written against), branch from there, write each file's `content` verbatim (whole file, no merge, no reformatting), then push or deploy however this project ships. `stale.head` / `stale.brief` say the repository or the CMS moved since it was approved: stop and ask for a fresh proposal. `receipt.paths.pending` lists every field the codemod could NOT bind, each with a reason (`DIALECT_UNSUPPORTED`, `PROP_TARGET_NOT_FOUND`, `REPEATER_FIXED_LENGTH`, \u2026) \u2014 apply the plan first, then bind those by hand. Then finish as a hand conversion does: get_binding_report until `unmatched` is empty, then set_binding_mode { declaredBindings: true } and release once more. 404 with `code: \"no-approved-plan\"` means nobody has approved one: use get_conversion_brief and do the conversion yourself. Requires artifact:write; on a workspace-wide connection pass `projectId`.",
3824
3830
  z.object({}).shape,
3825
3831
  async (c) => ok("Approved conversion plan.", await data(c, "GET", `/management/projects/current/conversion-plan`))
3826
3832
  ),
3827
3833
  def(
3828
3834
  "get_site_composition",
3829
3835
  "Check every page is assembled from registered components",
3830
- "The receipt for 'is every page assembled from registered components?' \u2014 read-only, computed live, nothing is written. Call it at the START of a whole-site componentize run (playbook \xA712) for the before picture, and at the END as the proof. Per page: `kind` ('blocks' | 'fields-only' | 'empty'), a block census, `nonComponentBlocks` (loose TOP-LEVEL bands the editor's section lane cannot outline), and every `component` placement with the component's `status` ('published' | 'draft' | 'missing' \u2014 a draft renders as an EMPTY STRING on the live site), whether it is `inPicker` (a component with no sectionType is dropped from 'Add a section'), and any `emptyRequiredProps` \u2014 `required` is an editor hint the write path does not enforce, so this read is the only thing that reports it. Site-wide: `components` counts published/draft, `awaitingEvidence` and `awaitingApproval` (evidence is exact and a HUMAN must approve it in the BetterCMS dashboard \u2014 no tool here can grant that, and there is no bulk approve), plus `notInPicker` and `unused` ids; `layout.placements` is the chrome placed in the Global Layout draft. `verdict.everyPageComposed` with `verdict.reasons` is the answer to give the user. Pages are read DRAFT-inclusive, so a page composed but not yet published still shows its placements \u2014 check each page's own `status` before claiming the site is live.",
3836
+ "The receipt for 'is every page assembled from registered components?' \u2014 read-only, computed live. Call it before a whole-site componentize run (playbook \xA712) and after it, as the proof. Per page: `kind` ('blocks' | 'fields-only' | 'empty'), a block census, `nonComponentBlocks` (loose TOP-LEVEL bands the editor's section lane cannot outline), and every `component` placement with the component's `status` ('published' | 'draft' | 'missing' \u2014 a draft renders as an EMPTY STRING on the live site), whether it is `inPicker` (a component with no sectionType is dropped from 'Add a section'), and any `emptyRequiredProps` \u2014 `required` is not enforced on write, so only this read reports it. Site-wide: `components` counts published/draft, `awaitingEvidence` and `awaitingApproval` (a HUMAN approves in the BetterCMS dashboard \u2014 no tool here can, and there is no bulk approve), plus `notInPicker` and `unused` ids; `layout.placements` is the chrome placed in the Global Layout draft. `verdict.everyPageComposed` with `verdict.reasons` is the answer to give the user. Pages are read DRAFT-inclusive: check each page's own `status` before claiming the site is live.",
3831
3837
  z.object({}).shape,
3832
3838
  async (c) => ok("Site composition.", await data(c, "GET", `/management/projects/current/composition`))
3833
3839
  ),
3834
3840
  def(
3835
3841
  "get_componentize_plan",
3836
3842
  "Get the plan for turning this site's sections into components",
3837
- "What this site's SECTIONS would become as components \u2014 a proposal that creates nothing, changes nothing and is computed live on every call. For a site whose pages were DERIVED at import (the site get_conversion_brief describes), each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import already folded (`group-*`). Per page it returns each section's `groupKey`, its `sectionType` family (Hero, FAQ, CTA, Features, Social proof\u2026), its leaf `fields` (key, path, type, the value the CMS holds), a `shapeHash`, and either the component that already renders it (`reuse.componentId`) or the one this plan proposes (`reuse.proposedSlug`) \u2014 and the components themselves under `components`: a NEW one carries the exact `props` and `blockJson` create_component would take, while a row for a component that ALREADY EXISTS carries its `componentId`, `slug` and `name` and no definition, because nothing will be written for it. Every page gets its OWN components (`home-hero`, `about-hero`), never one shared across pages; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (a dynamic page, `pattern: \"{slug}\"`) is planned ONCE, from one entry, and every prop reads the entry being rendered: only its prose body and lists of object rows are bands, while its frontmatter (author, images, tags, dates) stays the entry's own fields. `pending` says why a group is not offered: `NO_GROUP_ROOT` (the page's field keys are still the derive lane's own \u2014 `h1-welcome`, `p-we-build-things` \u2014 so there is no family to group by; rename them into families first), `NOT_A_SECTION` (a lone scalar with no family, or the page's own metadata \u2014 a section is a group field, a repeater, or a family two or more leaves share, so a legal page of `title`/`metaDescription`/`intro` proposes nothing; `detail: ENTRY_METADATA` is a collection template's frontmatter, `detail: DECLINED` a group somebody declined with componentize_sections { decline: true }), `IMAGE_AS_TEXT` (a text field holding an image path \u2014 it plans itself once the field is an image), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (a repeater THREE deep; TWO levels are expressed exactly \u2014 the group's `table` prop gains a nested `table` sub-field, and each row's nested column is an array of row objects), `PAGE_NOT_EMPTY` (the page holds blocks this lane does not own and will not overwrite). Chrome is NEVER a section: `nav-`/`footer-` keys and everything promoted into the project Layout are edited through the Layout. READ `summary: true` FIRST \u2014 groups, outcomes and slugs, no values (the full plan runs to hundreds of KB). `paged: true` returns 10 pages at a time with a `cursor` for the next; a 409 `PLAN_CHANGED` means the plan moved, start again. The unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
3843
+ "What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). Every page gets its OWN components; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (dynamic page, `pattern: \"{slug}\"`) is planned ONCE, every prop reading the rendered entry: only its prose body and object-row lists are bands; frontmatter (author, images, tags, dates) stays entry fields. `pending` says why a group is not offered: `NO_GROUP_ROOT` (keys are still derive-lane names like `h1-welcome` \u2014 rename them into families first), `NOT_A_SECTION` (a lone scalar or the page's own metadata; `detail: ENTRY_METADATA` = collection-template frontmatter, `detail: DECLINED` = declined with componentize_sections `decline`), `IMAGE_AS_TEXT` (a text field holding an image path \u2014 make it an image field), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (three deep; two levels become a nested `table` sub-field), `PAGE_NOT_EMPTY` (blocks this lane does not own and will not overwrite). Chrome (`nav-`/`footer-` keys, anything promoted into the Layout) is NEVER a section: edit it through the Layout. READ `summary: true` FIRST \u2014 the full plan runs to hundreds of KB; `paged` pages it (409 PLAN_CHANGED = start again); the unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
3838
3844
  z.object({
3839
3845
  summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first."),
3840
3846
  paged: z.boolean().optional().describe("true = 10 pages at a time; the response carries `page`, `pageCount` and a `cursor` for the next."),
@@ -3845,7 +3851,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
3845
3851
  def(
3846
3852
  "componentize_sections",
3847
3853
  "Turn this site's derived sections into components",
3848
- "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Read its `components.wouldDuplicate` before applying: each id is an existing component of the same family this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` moves the words onto each placement and marks the page's fields `origin: \"componentized\"` \u2014 kept, never deleted (see `copy`). Every component it CREATES is filed into a Component Group (the folders of the dashboard's Components tab): the one you name in `group` (found or created), else its section family \u2014 nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A component it reuses keeps the Group it has; the receipt's `components.groups` lists the Groups it filed into. When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized, the plan reports them `NOT_A_SECTION` / `DECLINED` and stops proposing them, and a plan that proposes nothing that was not declined is done; `decline: false` offers them again.",
3854
+ "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections, how many components it will create and which pages it will rewrite; `dryRun` first. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with ordered `component` instances, one per group, each carrying `props.bind: \"<groupKey>\"` \u2014 the page field group that already holds the copy. Nothing is copied or moved: the page keeps its `fields`, values and bindings, so click-to-edit and the coverage meter do not change (`copy: \"instance\"` moves the words onto each placement instead \u2014 see `copy`). Pass the plan's `digest`; a 409 `stale-plan` means the site changed: read the plan again, show the user what changed and confirm again. Read `components.wouldDuplicate` before applying: each id is an existing same-family component this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Re-running is safe: an already-placed group returns in `sections.pending` as ALREADY_COMPONENTIZED, with no second component. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages, then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. Every component it CREATES is filed into a Component Group (see `group`). When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized, the plan reports them `NOT_A_SECTION` / `DECLINED` and stops proposing them, and a plan that proposes nothing undeclined is done; `decline: false` offers them again.",
3849
3855
  z.object({
3850
3856
  digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
3851
3857
  pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
@@ -3853,7 +3859,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
3853
3859
  sections: z.array(z.string().min(1)).optional().describe("Act only on these section groupKeys (from the plan). Every other section on the page keeps the placement it already has."),
3854
3860
  copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy. 'bind' (default) leaves it in the page fields. 'instance' is the page-builder model: each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words."),
3855
3861
  dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first."),
3856
- group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (by NAME; created when missing). Omit to file each under its section family."),
3862
+ group: z.string().min(1).max(100).optional().describe("File the components this run creates into this Component Group (a folder of the dashboard's Components tab; by NAME, created when missing), else its section family: nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A reused component keeps its Group; the receipt's `components.groups` lists the Groups filed into."),
3857
3863
  decline: z.boolean().optional().describe("true = record `sections` as NOT sections (nothing is componentized; the plan stops proposing them). false = offer them again. Needs `sections`.")
3858
3864
  }).shape,
3859
3865
  async (c, a) => ok("Componentized the sections.", await data(c, "POST", `/management/projects/current/componentize`, { digest: a.digest, pageIds: a.pageIds, pages: a.pages, sections: a.sections, copy: a.copy, dryRun: a.dryRun, group: a.group, decline: a.decline }))
@@ -4168,7 +4174,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4168
4174
  def(
4169
4175
  "get_deploy_status",
4170
4176
  "Get deploy/build status",
4171
- "Get the connected project's deploy/build status: state (idle|queued|building|categorizing|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `categorizing` means the bytes are live but the import's setup lanes (content, pages, bindings) are still running, so `publishing` is still true and the site is not ready to edit yet. Poll this after deploy_project until state is idle with your sha live. Also returns `canvas.lane` (the editor's live-preview lane for this site: `bridge` / `draft-route` / `none`) and `editorUrl`, the visual editor to hand the user.",
4177
+ "Get the connected project's deploy/build status: state (idle|queued|building|categorizing|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `categorizing` means the bytes are live but the import's setup lanes (content, pages, bindings) are still running, so `publishing` is still true and the site is not ready to edit yet. Poll this after deploy_project until state is idle with your sha live. `release` is the newest release (id, sha, status, phaseMarks, error, createdAt): while an uploaded build ingests or releases, state is `building`, `release.sha` the new sha and `lastLiveSha` still the old one; a failed deploy shows `release.status` failed with its `error`, and `held` state (`held.by`: staff, archived or no-subdomain) means the release waits until that is cleared. Also returns `canvas.lane` (the editor's live-preview lane for this site: `bridge` / `draft-route` / `none`) and `editorUrl`, the visual editor to hand the user.",
4172
4178
  z.object({}).shape,
4173
4179
  async (c) => ok("Deploy status.", await data(c, "GET", `/management/projects/deploy-status`))
4174
4180
  ),
@@ -4289,7 +4295,7 @@ ${notes.join("\n")}` : summary, created);
4289
4295
  name: "create_content_model",
4290
4296
  config: {
4291
4297
  title: "Create a content model (reusable schema)",
4292
- description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). `fields` may NEST: type 'group' = one nested object of child fields; type 'repeater' = a repeatable array of child objects. Put child fields in each group/repeater's own `fields` (any depth). Pass kind:'block' to create a BLOCK type instead \u2014 see that argument. `fieldsets` ([{id, name}]) are the model's editor cards; a field joins one with `fieldsetId`. The slug must be unique in this project and branch: a taken one is refused with 409 `slug_taken` and free `suggestions`. " + RICH_TEXT_NOTE,
4298
+ description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). " + PAGES_NOT_COLLECTIONS_NOTE + " `fields` may NEST: type 'group' = one nested object of child fields; type 'repeater' = a repeatable array of child objects. Put child fields in each group/repeater's own `fields` (any depth). Pass kind:'block' to create a BLOCK type instead \u2014 see that argument. `fieldsets` ([{id, name}]) are the model's editor cards; a field joins one with `fieldsetId`. The slug must be unique in this project and branch: a taken one is refused with 409 `slug_taken` and free `suggestions`. " + RICH_TEXT_NOTE,
4293
4299
  inputSchema: createModelInput.shape
4294
4300
  },
4295
4301
  handler: guard(
@@ -4353,7 +4359,7 @@ ${notes.join("\n")}` : summary, created);
4353
4359
  name: "add_page_field",
4354
4360
  config: {
4355
4361
  title: "Add a field to a page",
4356
- description: "Append a field to an existing page's schema (Home, About, a blog template, etc.). Additive \u2014 the API rejects a key that already exists and never overwrites or retypes existing fields. Use this when the target is a page (singleton or dynamic); use add_field when the target is a content model. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields.",
4362
+ description: "Append a field to an existing page's schema (Home, About, a blog template, etc.). Additive \u2014 the API rejects a key that already exists and never overwrites or retypes existing fields. Use this when the target is a page (singleton or dynamic); use add_field when the target is a content model. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields. A 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section.",
4357
4363
  inputSchema: addPageFieldInput.shape
4358
4364
  },
4359
4365
  handler: guard(
@@ -4375,7 +4381,7 @@ ${notes.join("\n")}` : summary, created);
4375
4381
  name: "create_content_entry",
4376
4382
  config: {
4377
4383
  title: "Create a content entry",
4378
- description: "Create a content entry under a model. Pass its field VALUES in `data`, keyed by field key \u2014 INCLUDE ALL REQUIRED FIELDS (create validates them). New entries are drafts; pass status:'published' to take it live. Read get_content_model first for the field keys and which are required.",
4384
+ description: "Create a content entry under a model. " + PAGES_NOT_COLLECTIONS_NOTE + " Pass its field VALUES in `data`, keyed by field key \u2014 INCLUDE ALL REQUIRED FIELDS (create validates them). New entries are drafts; pass status:'published' to take it live. Read get_content_model first for the field keys and which are required.",
4379
4385
  inputSchema: createEntryInput.shape
4380
4386
  },
4381
4387
  handler: guard(
@@ -4399,14 +4405,15 @@ ${notes.join("\n")}` : summary, created);
4399
4405
  name: "set_page_content",
4400
4406
  config: {
4401
4407
  title: "Set a page's field values (content)",
4402
- description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_content_entry is for dynamic collections only.",
4408
+ description: "Set a page's field VALUES \u2014 the actual content. For a SINGLETON page (Home, About, Site Settings) this creates or updates its one entry, so call it again to edit. `data` is keyed by field key: a nested 'array' (zone) value is an OBJECT { nonRepeatable: { childKey: value }, repeatable: [ { childKey: value } ] }; a primitive 'array' is a plain list; an 'image' value is an asset URL. Read the schema first with get_page. This is how you populate Home/About/Settings \u2014 create_content_entry is for dynamic collections only. Merges by top-level key (a zone value replaces that whole zone); pass replace:true to remove omitted fields.",
4403
4409
  inputSchema: setPageContentInput.shape
4404
4410
  },
4405
4411
  handler: guard(
4406
4412
  async (args) => withClient(async (client) => {
4407
4413
  const entry = await client.setPageContent(args.pageId, {
4408
4414
  data: args.data,
4409
- ...args.status !== void 0 ? { status: args.status } : {}
4415
+ ...args.status !== void 0 ? { status: args.status } : {},
4416
+ ...args.replace !== void 0 ? { replace: args.replace } : {}
4410
4417
  });
4411
4418
  return ok(
4412
4419
  `Set content on page ${args.pageId} (entry ${entry.id}, status ${entry.status}).`,
@@ -4743,7 +4750,7 @@ ${notes.join("\n")}` : summary, created);
4743
4750
  name: "create_components",
4744
4751
  config: {
4745
4752
  title: "Create many components in one call",
4746
- description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component whenever you are making more than three: a whole-site componentize run (playbook \xA712) is dozens, and one call each spends the turn on plumbing. Each item reports its own `{ ok, id, slug, error }`: a failure does NOT stop the run and the successful rows stay, so read the receipt and retry only the failures (a 409 on `slug` means that name is taken \u2014 change it, do not re-run the batch). Every component lands as a DRAFT, exactly as create_component does \u2014 it renders as NOTHING on the live site until it is published, which needs the owner's approval in the dashboard. Pass `group` on each item to file it into the folder editors browse. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: inside a component only the leaves a declared prop TARGETS are click-to-edit, and copy no prop points at is reachable neither from the canvas nor from the dock. A page is composed of SECTIONS; the full doctrine is in the bettercms://playbook/schema resource, \xA712.",
4753
+ description: "Create up to 50 components in ONE call \u2014 the same input as create_component, once per item. Prefer it over create_component for more than three: a whole-site componentize run (playbook \xA712) is dozens. Each item reports its own `{ ok, id, slug, error }`: a failure does NOT stop the run and the successful rows stay, so read the receipt and retry only the failures (a 409 on `slug` means that name is taken \u2014 change it, do not re-run the batch). Every component lands as a DRAFT, exactly as create_component does \u2014 it renders as NOTHING on the live site until it is published, which needs the owner's approval in the dashboard. Pass `group` on each item to file it into the folder editors browse. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a declared prop TARGETS are click-to-edit; copy no prop points at is unreachable from canvas and dock alike. A page is composed of SECTIONS; the full doctrine is in the bettercms://playbook/schema resource, \xA712.",
4747
4754
  inputSchema: createComponentsInput.shape
4748
4755
  },
4749
4756
  handler: guard(
@@ -4769,7 +4776,7 @@ ${notes.join("\n")}` : summary, created);
4769
4776
  name: "publish_components",
4770
4777
  config: {
4771
4778
  title: "Publish many components in one call",
4772
- description: "Publish up to 100 components in ONE call \u2014 the same act as publish_component, once per item, copying each DRAFT definition to the live copy. Each item reports `{ ok, status, error, readiness }` and a failure never stops the run. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it, and it has no bulk form. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. Report its `readiness`. The item's `readiness` names what is missing \u2014 relay that to the user as the list of what only they can approve. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish; 422 means the publish would break a Layout, and the item names which.",
4779
+ description: "Publish up to 100 components in ONE call \u2014 the same act as publish_component, once per item, copying each DRAFT definition to the live copy. Each item reports `{ ok, status, error, readiness }` and a failure never stops the run. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it, and it has no bulk form. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. The item's `readiness` names what is missing \u2014 relay that to the user as the list of what only they can approve. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish; 422 means the publish would break a Layout, and the item names which.",
4773
4780
  inputSchema: publishComponentsInput.shape
4774
4781
  },
4775
4782
  handler: guard(
@@ -4804,7 +4811,7 @@ ${notes.join("\n")}` : summary, created);
4804
4811
  name: "compose_pages",
4805
4812
  config: {
4806
4813
  title: "Set many pages' block composition in one call",
4807
- description: "Give up to 50 pages their block composition in ONE call \u2014 the same write as update_page's `blockJson`, once per item, and it REPLACES each page's whole block array. Address a page by `pageId` or by `slug`. Each item reports `{ ok, pageId, slug, error }` and a failure does not stop the run. BEFORE writing anything it reads this project's component catalogue once and REFUSES any item placing a componentId the project does not have \u2014 that page would render the missing sections as empty strings with no error anywhere, which is the single hardest failure on this platform to trace back. Writes DRAFTS: publish each page with update_page status:'published' afterwards. A page is composed of SECTIONS: a recurring band is a component with a `sectionType`, a one-off band is a `section` block whose `props.children` hold its blocks \u2014 never loose top-level heading/text/image blocks, which no editor can move, name or swap as a unit. The full doctrine is in the bettercms://playbook/schema resource, \xA712.",
4814
+ description: "Give up to 50 pages their block composition in ONE call \u2014 the same write as update_page's `blockJson`, once per item, and it REPLACES each page's whole block array. Address a page by `pageId` or by `slug`. Each item reports `{ ok, pageId, slug, error }` and a failure does not stop the run. BEFORE writing anything it reads this project's component catalogue once and REFUSES any item placing a componentId the project does not have \u2014 its missing sections would render as empty strings with no error anywhere. Writes DRAFTS: publish each page with update_page status:'published' afterwards. A page is composed of SECTIONS: a recurring band is a component with a `sectionType`, a one-off band is a `section` block whose `props.children` hold its blocks \u2014 never loose top-level heading/text/image blocks, which no editor can move, name or swap as a unit. The full doctrine is in the bettercms://playbook/schema resource, \xA712.",
4808
4815
  inputSchema: composePagesInput.shape
4809
4816
  },
4810
4817
  handler: guard(
@@ -4908,7 +4915,7 @@ ${lines.join("\n")}`, found);
4908
4915
  name: "set_component_source",
4909
4916
  config: {
4910
4917
  title: "Record which file implements a component",
4911
- description: "Call this RIGHT AFTER you write or locate the code for a component in the app's repository. It tells BetterCMS which file renders the component, so the dashboard's Output button can build a live preview and validate it with no setup. Without it Output says no source is recorded and nothing is validated. Record ONLY a file that renders exactly this component from its own fields: a component whose props are this component's field keys (`kind: 'file'`, the default), or a section the codemod extracted with `npx @bettercms-ai/convert --componentize` \u2014 a file starting `// @bettercms-ai/convert section` whose props are `{ blockId, bind, overrides, page }` (`kind: 'section'`). Never record a different component that merely contains this markup (e.g. a card that needs a `post` object): it cannot render from these fields and validation fails. If the markup is inline in a page, extract it into a section first. `path` is relative to the app root (e.g. 'src/components/sections/Hero.astro'); `export` is the export name, omitted for a default export. Call it again if the file moves; call clear_component_source if a recorded file is wrong.",
4918
+ description: "Call this RIGHT AFTER you write or locate a component's code in the app's repository: it records which file renders it, so the dashboard's Output button can preview and validate it with no setup (without it nothing is validated). Record ONLY a file that renders exactly this component from its own fields: a component whose props are this component's field keys (`kind: 'file'`, the default), or a section the codemod extracted with `npx @bettercms-ai/convert --componentize` \u2014 a file starting `// @bettercms-ai/convert section` whose props are `{ blockId, bind, overrides, page }` (`kind: 'section'`). Never record a different component that merely contains this markup (e.g. a card that needs a `post` object): it cannot render from these fields and validation fails. If the markup is inline in a page, extract it into a section first. `path` is relative to the app root (e.g. 'src/components/sections/Hero.astro'); `export` is the export name, omitted for a default export. Call it again if the file moves; call clear_component_source if a recorded file is wrong.",
4912
4919
  inputSchema: setComponentSourceInput.shape
4913
4920
  },
4914
4921
  handler: guard(
@@ -5013,7 +5020,7 @@ ${lines.join("\n")}`, found);
5013
5020
  name: "publish_component",
5014
5021
  config: {
5015
5022
  title: "Publish a component",
5016
- description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. create_component and update_component write drafts, and an unpublished component renders as NOTHING live, with no error. Publish every component you place on a page. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. Report its `readiness`. The response's `readiness` names what is missing \u2014 relay it to the user instead of retrying or routing around it. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
5023
+ description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. Drafts (create_component, update_component) render as NOTHING live, with no error: publish every component you place on a page. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. Its `readiness` names what is missing \u2014 relay it to the user instead of retrying or routing around it. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
5017
5024
  inputSchema: getComponentInput.shape
5018
5025
  },
5019
5026
  handler: guard(
@@ -5170,7 +5177,10 @@ DERIVED at import, where the componentize lane in \xA710 reuses every field.
5170
5177
  Repeated visual region on a page? -> a COMPONENT with sectionType (a Section)
5171
5178
  ...and it comes in more than one look? -> siblings sharing that sectionType = VARIANTS
5172
5179
  A stack of mixed, reorderable content? -> kind:'block' models + a \`modular\` field
5173
- A row in a list (post, author, tier)? -> a collection (kind:'model')
5180
+ A row in a list (post, author, tier)? -> a collection (kind:'model'); rows one template
5181
+ route renders get urlPattern, e.g. '/blog/:slug'
5182
+ Copy for ONE page (home, about, legal)?-> page fields in sections (add_page_field /
5183
+ set_page_content) \u2014 never a one-entry collection
5174
5184
  The one body of an article? -> type:'document' (exactly one, top level)
5175
5185
  A fixed cluster of fields? -> group (never a 1-item repeater)
5176
5186
  A repeating cluster? -> repeater
@@ -5890,8 +5900,8 @@ delivered value \u2014 they change editor chrome only.
5890
5900
  BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
5891
5901
  key you reference must exist.
5892
5902
  - **\`ui.collapsed\`** \u2014 start a nesting field's panel closed. For long optional sections.
5893
- - **\`ui.preview\`** \u2014 how ONE ITEM of a nesting field summarises itself when collapsed. Both
5894
- slots name a **CHILD FIELD KEY of that field** \u2014 not a value, not a dotted path.
5903
+ - **\`ui.preview\`** \u2014 how ONE ITEM of a nesting field summarises itself when collapsed. Each
5904
+ slot (\`title\`, \`subtitle\`, \`media\`) names a **CHILD FIELD KEY of that field** \u2014 not a value, not a dotted path.
5895
5905
  Without it a repeater of ten testimonials reads "Item 1 \u2026 Item 10" and the author has to open
5896
5906
  each one to find the one they meant. \`media\` may point at an \`image\`, \`file\` or url-ish
5897
5907
  \`text\` child; the type is not constrained.
@@ -5903,19 +5913,25 @@ delivered value \u2014 they change editor chrome only.
5903
5913
  in that order, a nav whose order is semantic, a timeline. Locking a list nobody should reorder
5904
5914
  is the declaration there was previously no way to make; locking one out of tidiness takes a
5905
5915
  capability away from the author, so do not set it "just in case".
5916
+ - **\`ui.control\`** \u2014 which control a LEAF edits with; omit it for the default. \`'segmented'\` on a
5917
+ \`select\` with a few short options (alignment, theme, size: one click instead of a dropdown);
5918
+ \`'slider'\` on a \`number\` with \`config.min\` < \`config.max\` (opacity, columns). Anywhere else it
5919
+ is refused with the rule named.
5906
5920
 
5907
- **Five refusals, all deliberate, all 400 with the fix in the message.**
5921
+ **Six refusals, all deliberate, all 400 with the fix in the message.**
5908
5922
  1. An unknown key inside \`ui\` is REJECTED, not stripped. \`ui\` is a strict object precisely so
5909
5923
  that a typo is a refusal you can read instead of a 200 with your declaration gone.
5910
5924
  2. \`ui.preview\` on a LEAF field is refused \u2014 it summarises an item, and a leaf has no items.
5911
5925
  Valid on \`group\`, \`repeater\`, an \`array\` with \`config.zones\`, or \`modular\`.
5912
- 3. \`preview.title\` / \`preview.media\` naming a key that is not a child of that field is refused,
5926
+ 3. \`preview.title\` / \`preview.subtitle\` / \`preview.media\` naming a key that is not a child of that field is refused,
5913
5927
  and the error lists the valid child keys.
5914
5928
  4. \`ui.layout\` follows the same rule as \`preview\`: nesting fields only. A leaf has no items
5915
5929
  to arrange.
5916
5930
  5. \`ui.reorderable\` is STRICTER \u2014 it needs a LIST, not merely children. Valid on \`repeater\`,
5917
5931
  an \`array\` with \`config.zones.repeatable\`, or \`modular\`. A \`group\` (and a non-repeatable
5918
5932
  zone) holds exactly ONE item, so there is nothing there to drag and the write is refused.
5933
+ 6. \`ui.control\` outside its fit is refused: \`segmented\` needs a \`select\`, \`slider\` a \`number\`
5934
+ whose \`config.min\` and \`config.max\` are both set with min < max.
5919
5935
 
5920
5936
  **The same \`ui\` rides on COMPONENT PROPS.** \`create_component\` / \`update_component\` take it on
5921
5937
  each entry of \`props\`, with the placement rules the prop vocabulary implies: \`preview\` and
@@ -6577,9 +6593,10 @@ Read bettercms://playbook/schema section 14 first \u2014 it has the exact prop s
6577
6593
  **What you are setting** (all of these ride on any field, on create_content_model / create_page /
6578
6594
  add_field / add_page_field; none of them affect delivery):
6579
6595
  ui.collapsed start a nesting field's panel closed
6580
- ui.preview {title,media} which CHILD FIELD KEY titles a collapsed row, and which is its thumb
6596
+ ui.preview {title,subtitle,media} which CHILD FIELD KEY titles a collapsed row, its second line, its thumb
6581
6597
  ui.layout 'list'|'grid'|'table' how its items are arranged; table is for repeatable row schemas; omit for list
6582
6598
  ui.reorderable omit (= true) unless the ORDER IS THE MEANING; false LOCKS the list
6599
+ ui.control 'segmented'|'slider' a LEAF's control: segmented on a select, slider on a number with config.min < max
6583
6600
  helpText one line under the field: what to WRITE here
6584
6601
  showIf hide a field until another field has a value
6585
6602