@bettercms-ai/mcp 0.53.1 → 0.54.1

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
@@ -2280,7 +2280,9 @@ var fieldShape = {
2280
2280
  ),
2281
2281
  label: z.string().min(1).describe("human label shown in the editor"),
2282
2282
  type: fieldType,
2283
- required: z.boolean().optional(),
2283
+ required: z.boolean().optional().describe(
2284
+ "an entry cannot be saved without a value \u2014 set it only where the site genuinely cannot render without one"
2285
+ ),
2284
2286
  richText: z.boolean().optional().describe(
2285
2287
  "prose formatting. DEFAULTS TO TRUE for 'text': the field is created as rich text so editors can bold, link and format it on the canvas, and the API returns rich text (render with rich() from @bettercms-ai/sdk; plain() for titles and meta). Pass false for a value that is NOT prose and must stay a bare string \u2014 a URL/href, a slug, an id, an email, a phone number, a CSS class, an icon name. A link stored as rich text will not work as an href."
2286
2288
  ),
@@ -2288,7 +2290,7 @@ var fieldShape = {
2288
2290
  "choices when type is 'select'. A plain string, or {label, value} when the stored value differs from what the editor shows (e.g. {label:'Sold out', value:'soldOut'})."
2289
2291
  ),
2290
2292
  config: z.record(z.string(), z.unknown()).optional().describe(
2291
- "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model"
2293
+ "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime}, modular {blockSlugs: ['quote','gallery'], minItems?, maxItems?} \u2014 blockSlugs is REQUIRED, non-empty, and each slug must name an existing kind:'block' model; richtext {inline:true, hostTag:'h2', accept:['h2','p','b','i','u','link'], features:['bold','italic','underline','link','headings'], allowMultipleParagraphs:false} \u2014 for a field bound to ONE element on the page: `hostTag` is that element's tag and `accept` must name it, so the editor's block-style control opens already showing 'Heading 2' instead of 'Paragraph'. Name exactly ONE heading level: publish refuses an <h3> value bound to an <h2> rather than nesting one heading inside the other. Keep 'p' in `accept` or the sanitiser strips a block demoted to Paragraph"
2292
2294
  ),
2293
2295
  fields: z.array(z.lazy(() => fieldObject)).optional().describe(
2294
2296
  "NESTED child fields \u2014 REQUIRED for type 'group' (one nested object, a Non-Repeatable Zone like blog_hero \u2192 heading, description, hero_image) and type 'repeater' (a repeatable array of such objects, a Repeatable Zone / section-list like testimonials \u2192 quote, author). A section with repeating items is a 'repeater'; a fixed grouped block is a 'group'. Recurse to any depth \u2014 do NOT flatten zones into separate top-level fields."
@@ -2314,7 +2316,7 @@ var fieldShape = {
2314
2316
  "conditional visibility \u2014 hide this field until another field has a given value (e.g. show `external_url` only when `link_type` is 'external'). Admin-only: it changes what an author SEES, never what is stored or delivered. Rules reference fields by BARE KEY, and the key namespace is flat, so the key must exist on this model."
2315
2317
  ),
2316
2318
  ui: uiObject.optional().describe(
2317
- "AUTHORING CHROME \u2014 how this field presents in the editor. Never a delivery effect. `preview` and `layout` are valid ONLY on a nesting field ('group', 'repeater', an 'array' with config.zones, or 'modular'); `reorderable` only where there is a LIST to lock ('repeater', an 'array' with config.zones.repeatable, or 'modular' \u2014 a 'group' holds one item). preview keys must name real children, or the write is refused with the valid keys listed. An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_content_model / get_page to confirm it landed. There is deliberately no `order`: field order is the array index \u2014 use add_field's `after` to insert."
2319
+ "AUTHORING CHROME \u2014 how this field presents in the editor; never a delivery effect. `preview` slots name a CHILD FIELD KEY and must name real children, or the write is refused with the valid keys listed. `preview`/`layout` need a nesting field, `reorderable` needs a list, and an unknown key inside `ui` is a 400, not a silent strip. No `order`: field order is the array index \u2014 use `after`. Section 14 of bettercms://playbook/schema has each key and where it is legal."
2318
2320
  ),
2319
2321
  // ── G5: the four keys the backend field schema accepts that this whitelist stripped. ──
2320
2322
  fieldsetId: z.string().min(1).optional().describe(
@@ -2424,11 +2426,12 @@ function toField(f) {
2424
2426
  ...f.config ? { config: f.config } : {}
2425
2427
  };
2426
2428
  }
2427
- var BRAND_KIT_NOTE = "`brandKit` is the project's brand kit, read-only: colors, typography, radius and any typeScale/spacing/elevation/motion. It is edited in the BetterCMS dashboard; style sections with their `style` tokens rather than copying its values into props.";
2429
+ 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.";
2428
2430
  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.";
2429
2431
  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.";
2430
2432
  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).";
2431
- var STRUCTURE_NOTE = '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. Folders nest at most 4 levels deep (a page or collection may sit in a level-4 folder). Icons are lucide icon names, e.g. "folder", "file-text", "rows-3", "house" (a-z, 0-9 and dashes); null clears one. SYSTEM FOLDERS (CPO-129): "home:pages" (Other pages), "home:collections" (Shared) and "home:globals" (Settings) always exist, even when the document has no record for them. They close the sidebar, below the folders you make, and can never be moved or deleted (400 NAV_SYSTEM_FOLDER), but rename_folder and set_icon work on them and create their record on first use. TYPE PURITY: anything inside Other pages, at any depth, must be a page, and anything inside Shared must be a collection, and Settings holds NO pages or collections at all, because globals are not sidebar items (subfolders under Settings are allowed but stay empty). Any of these is 400 NAV_TYPE_MISMATCH. Your own folders hold either kind. ACCESS (CPO-131): Requires Admin or Developer access to CHANGE the structure, the same access as editing the schema; anyone who can read content can call get_content_structure. A 403 SCHEMA_ACCESS_REQUIRED means the person behind this connection is not an Admin or Developer: stop and tell the user, do not retry. ROOT PINS (CPO-132): folderId \'root\' pins a page or collection at the top level; null returns it to its home (Other pages / Shared). 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). KIND CHECK: an id is either a page or a collection, never both, and a custom folder holds either \u2014 so move_to_folder and set_icon VERIFY that the id really is the `kind` you named on this branch, and refuse a mismatch with 400 NAV_KIND_MISMATCH naming the kind to use; call it again with that kind, which also clears the wrong-kind entry. An id that resolves to nothing is allowed (it may exist on another branch) and the result says so. set_content_structure never refuses: it returns `warnings` and `kindMismatches`. get_content_structure marks such items `kindMismatch: true` and lists them in `summary.kindMismatches`. PAGE-BOUND MODELS (CPO-135): a page with fields has a content model bound to it. That model is NOT a collection \u2014 get_content_structure never lists it among collections or in the tree, and move_to_folder / set_icon / set_content_structure refuse it with 400 NAV_PAGE_BOUND_MODEL naming the owning page (\'This model belongs to the page "\u2026"; organise the page.\'). Organise the PAGE instead, by its page id. If a stored document still references one it appears in `summary.hiddenItems` with `boundToPageId`, and the next single write drops it.';
2433
+ 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. ';
2434
+ 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.";
2432
2435
  function structureResult(d) {
2433
2436
  const message = d?.message;
2434
2437
  return ok(typeof message === "string" ? message : "Updated the Content structure.", d);
@@ -2522,7 +2525,7 @@ function authPrompt(err) {
2522
2525
  }
2523
2526
  var currentProject = new AsyncLocalStorage();
2524
2527
  var projectIdArg = z.string().min(1).optional().describe(
2525
- "only needed when your grant covers the whole WORKSPACE: the project to act on (from list_projects). A project-scoped key ignores it."
2528
+ "target project id (from list_projects); only for a workspace-wide grant."
2526
2529
  );
2527
2530
  var withProjectId = (def) => ({
2528
2531
  ...def,
@@ -2532,6 +2535,47 @@ var withProjectId = (def) => ({
2532
2535
  return currentProject.run(project, () => def.handler(args));
2533
2536
  }
2534
2537
  });
2538
+ var LISTING_TAIL = /* @__PURE__ */ new Set([
2539
+ // The agent-job lane: a dashboard surface an agent rarely drives.
2540
+ "list_ai_jobs",
2541
+ "get_ai_job",
2542
+ "approve_ai_job",
2543
+ "reject_ai_job",
2544
+ "list_ai_reports",
2545
+ "connect_native_ai",
2546
+ "native_bridge_heartbeat",
2547
+ // Section evidence and component validation: a CI lane, not an authoring one.
2548
+ "claim_section_validation_request",
2549
+ "complete_section_validation_request",
2550
+ "fail_section_validation_request",
2551
+ "list_section_validation_requests",
2552
+ "submit_section_validation",
2553
+ "submit_section_manifest",
2554
+ "request_component_validation",
2555
+ "declare_component_route",
2556
+ "clear_component_source",
2557
+ "submit_componentize_receipt",
2558
+ // Reporting, history and workflow chrome.
2559
+ "get_analytics_overview",
2560
+ "get_analytics_top_pages",
2561
+ "list_activity",
2562
+ "get_workflow_board",
2563
+ "move_entry_stage",
2564
+ "move_page_stage",
2565
+ "list_entry_versions",
2566
+ "restore_entry_version",
2567
+ // One-off project operations and the extraction lane.
2568
+ "clone_project",
2569
+ "promote_project",
2570
+ "generate_pages_from_dataset",
2571
+ "extract_component",
2572
+ "list_extraction_candidates",
2573
+ "create_deploy_upload",
2574
+ "deploy_from_upload",
2575
+ "set_authoring_preference",
2576
+ "set_media_delivery",
2577
+ "delete_form_submission"
2578
+ ]);
2535
2579
  function buildToolDefs(deps) {
2536
2580
  async function withClient(fn) {
2537
2581
  const token = await deps.auth.getAccessToken();
@@ -2586,7 +2630,7 @@ function buildToolDefs(deps) {
2586
2630
  ]).describe("block type; section/slider/tabs/columns nest child blocks"),
2587
2631
  id: z.string().min(1).describe("stable unique block id"),
2588
2632
  props: z.record(z.string(), z.unknown()).describe(
2589
- "per-type props: heading {text, level}; text/richtext {html} (NOT {text}); image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
2633
+ "per-type props: heading {text, level}; text/richtext {html} (NOT {text}), and both also take level 1-6, rendering the html AS that <hN> with inline marks kept (one line of inline copy only) \u2014 use it for a title that carries a styled span; image {src, alt}; button {text, href}; spacer {height}; video {url}; form {formId}; component {componentId, overrides?}; navbar {links:[{label,href}], logo?, cta?}; footer {columns, copyright?}; section {children: block[]}; columns {columns: block[][], gap} \u2014 a column may NOT hold columns/section/slider/tabs; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
2590
2634
  ),
2591
2635
  style: z.record(z.string(), z.unknown()).optional().describe(
2592
2636
  "design tokens: theme, bg (none|surface|muted|accent|dark|custom), bgCustom hex, paddingTop/paddingBottom/paddingSides px, contentWidth (narrow|default|wide|full), align, corner, shadow, borderTop/borderBottom. A real marketing band is a `section` block carrying bg + padding + contentWidth."
@@ -2748,8 +2792,11 @@ function buildToolDefs(deps) {
2748
2792
  ]),
2749
2793
  placeholder: z.string().optional(),
2750
2794
  helpText: z.string().optional().describe("hint shown under the control, muted"),
2751
- required: z.boolean().optional(),
2795
+ required: z.boolean().optional().describe(
2796
+ "an entry cannot be saved without a value \u2014 set it only where the site genuinely cannot render without one"
2797
+ ),
2752
2798
  options: z.array(z.string()).optional().describe("choices when type is 'select', 'radio' or 'checkboxes'"),
2799
+ optionValues: z.array(z.string()).optional().describe("what each choice SUBMITS, positionally paired with `options` \u2014 only where it differs from the label"),
2753
2800
  hidden: z.boolean().optional().describe("not rendered; pairs with defaultValue to capture context"),
2754
2801
  defaultValue: z.string().optional(),
2755
2802
  showIf: z.object({ field: z.string(), equals: z.string() }).optional().describe("show this field only when another field equals a value"),
@@ -3154,11 +3201,11 @@ function buildToolDefs(deps) {
3154
3201
  z.object({ formId: z.string().min(1), submissionId: z.string().min(1) }).shape,
3155
3202
  async (c, a) => ok("Deleted submission.", await data(c, "DELETE", `/management/forms/${s(a.formId)}/submissions/${s(a.submissionId)}`))
3156
3203
  ),
3157
- // ── Content structure: the Content sidebar's folders and icons (CPO-127 f) ──
3204
+ // ── Content structure: the Content sidebar's folders and icons ──
3158
3205
  def(
3159
3206
  "get_content_structure",
3160
3207
  "Get the Content sidebar structure",
3161
- `Read the Content sidebar's structure for the connected project: its folders, which pages and collections sit in each, and their icons. Returns { doc, version, summary }: \`doc\` is the raw document ({ schema: 1, folders: [{ id, parentId, name, icon, sort }], items: [{ kind: 'page'|'collection', id, folderId, sort, icon }] }), \`version\` is what set_content_structure needs, and \`summary\` is the resolved tree with page and collection TITLES (\`outline\` is a readable version), ids that no longer resolve (\`missing\`), and the pages and collections not in any folder yet. Call it before changing the structure, and read the resource ${STRUCTURE_PLAYBOOK_URI} (the structure standard) before you organise anything. ${STRUCTURE_NOTE}`,
3208
+ `Returns { doc, version, summary }: \`doc\` is the raw document ({ schema: 1, folders: [{ id, parentId, name, icon, sort }], items: [{ kind: 'page'|'collection', id, folderId, sort, icon }] }), \`version\` is what set_content_structure needs, and \`summary\` is the resolved tree with page and collection TITLES (\`outline\` is a readable version), ids that no longer resolve (\`missing\`), and the pages and collections not in any folder yet. Call it before changing the structure, and read the resource ${STRUCTURE_PLAYBOOK_URI} (the structure standard) before you organise anything. ${STRUCTURE_NOTE}`,
3162
3209
  z.object({}).shape,
3163
3210
  async (c) => {
3164
3211
  const d = await data(c, "GET", `/management/content-structure`);
@@ -3182,7 +3229,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3182
3229
  def(
3183
3230
  "set_content_structure",
3184
3231
  "Replace the Content sidebar structure",
3185
- `Replace the whole Content sidebar document in one write. Follow the structure standard (read ${STRUCTURE_PLAYBOOK_URI} first; suggest_content_structure returns a ready doc). Prefer the single-action tools (create_folder, rename_folder, move_to_folder, set_icon, delete_folder) for small changes; use this to lay out a full structure at once. Send the complete \`doc\` and the \`version\` get_content_structure returned (0 when the project has none yet). Validated exactly like the dashboard: unique folder ids, every parentId/folderId must be a folder in the doc, no cycles, depth at most 4, names 1-80 chars, lucide icon names. A 412 VERSION_CONFLICT means someone changed it since you read it: call get_content_structure again and re-apply your change. Returns the new version and a diff (folders added, removed, renamed or moved; items moved; icons changed). ${STRUCTURE_NOTE}`,
3232
+ `Replace the whole Content sidebar document in one write. Follow the structure standard (read ${STRUCTURE_PLAYBOOK_URI} first; suggest_content_structure returns a ready doc). Send the complete \`doc\` and the \`version\` get_content_structure returned (0 when the project has none yet). Validated exactly like the dashboard: unique folder ids, every parentId/folderId a folder in the doc, no cycles, depth \u22644, names 1-80 chars, lucide icons. A 412 VERSION_CONFLICT means someone changed it since you read it: read again and re-apply. Returns the new version and a diff. ${STRUCTURE_FENCE}`,
3186
3233
  z.object({
3187
3234
  doc: z.object({
3188
3235
  schema: z.literal(1),
@@ -3208,7 +3255,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3208
3255
  def(
3209
3256
  "create_folder",
3210
3257
  "Create a Content sidebar folder",
3211
- `Create a folder in the Content sidebar, at the top level or inside \`parentId\`, placed after what is already there. Returns the new \`folderId\`. Reads, applies and writes with the version check, retrying once on a conflict. ${STRUCTURE_NOTE}`,
3258
+ `Create a folder in the Content sidebar, at the top level or inside \`parentId\`, placed after what is already there. Returns the new \`folderId\`. Reads, applies and writes with the version check, retrying once on a conflict. ${STRUCTURE_FENCE}`,
3212
3259
  z.object({
3213
3260
  name: z.string().min(1).describe("the folder's label, 1-80 chars"),
3214
3261
  parentId: z.string().min(1).optional().describe("optional parent folder id (from get_content_structure); omit it, or send 'root', for the top level"),
@@ -3219,7 +3266,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3219
3266
  def(
3220
3267
  "rename_folder",
3221
3268
  "Rename a Content sidebar folder",
3222
- `Rename a Content sidebar folder. ${STRUCTURE_NOTE}`,
3269
+ `Rename a Content sidebar folder. ${STRUCTURE_FENCE}`,
3223
3270
  z.object({
3224
3271
  folderId: z.string().min(1).describe("folder id (from get_content_structure)"),
3225
3272
  name: z.string().min(1).describe("the new label, 1-80 chars")
@@ -3229,20 +3276,54 @@ ${d.outline}` : "Proposed Content structure.", d);
3229
3276
  def(
3230
3277
  "move_to_folder",
3231
3278
  "Move into a Content sidebar folder",
3232
- `Move a page, a collection or a folder into a folder. folderId null moves it back to its home: Other pages for a page, the Shared list for a collection, the top level for a folder. It lands after what is already there. Moving a folder takes its contents with it and is refused if the result would nest deeper than 4 levels or put a folder inside itself. ${STRUCTURE_NOTE}`,
3279
+ `Move a page, a collection or a folder into a folder. folderId null moves it back to its home: Other pages for a page, the Shared list for a collection, the top level for a folder. It lands after what is already there. Moving a folder takes its contents with it and is refused if the result would nest deeper than 4 levels or put a folder inside itself. ${STRUCTURE_FENCE}`,
3233
3280
  z.object({
3234
- kind: z.enum(["page", "collection", "folder"]),
3281
+ kind: z.enum(["page", "collection", "folder"]).describe(
3282
+ "what the id IS on this branch \u2014 VERIFIED, not assumed: an id is either a page or a collection, never both, and a mismatch is refused with 400 NAV_KIND_MISMATCH naming the kind to use. Call it again with that kind, which also clears the wrong-kind entry."
3283
+ ),
3235
3284
  id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
3236
3285
  folderId: z.string().min(1).nullable().describe("target folder id; 'root' pins a page or collection at the top level (for a folder, 'root' is the top level); null moves it back to its home (Other pages, the Shared list, or the top level for a folder)")
3237
3286
  }).shape,
3238
3287
  async (c, a) => structureResult(await data(c, "POST", `/management/content-structure/move`, { kind: a.kind, id: a.id, folderId: a.folderId }))
3239
3288
  ),
3289
+ def(
3290
+ "set_brand_assets",
3291
+ "Point the brand's logo or favicon at a media asset",
3292
+ `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}`,
3293
+ z.object({
3294
+ mark: z.string().min(1).nullable().optional().describe("media asset id for the logo; null clears it"),
3295
+ favicon: z.string().min(1).nullable().optional().describe("media asset id for the favicon; null clears it"),
3296
+ replace: z.boolean().optional().describe("override a slot a person chose (default false)")
3297
+ }).shape,
3298
+ async (c, a) => ok("Brand assets.", await data(c, "POST", `/management/projects/current/brand-kit/assets`, { mark: a.mark, favicon: a.favicon, replace: a.replace }))
3299
+ ),
3300
+ def(
3301
+ "set_brand_graphics",
3302
+ "Add, replace or remove the brand's gradients",
3303
+ `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}`,
3304
+ z.object({
3305
+ upsert: z.array(z.object({
3306
+ key: z.string().min(1).describe("token key: lowercase letters, digits and hyphens. Emitted as --brand-gradient-<key>"),
3307
+ label: z.string().optional(),
3308
+ kind: z.enum(["linear", "radial", "conic"]),
3309
+ angle: z.number().optional().describe("degrees 0-360; ignored for radial"),
3310
+ stops: z.array(z.object({
3311
+ color: z.string().min(1).describe("a kit colour KEY \u2014 a colors.* role name or an extras key. Never a hex."),
3312
+ at: z.number().describe("position 0-100")
3313
+ })).min(2).max(8)
3314
+ })).max(12).optional().describe("gradients to add or replace, matched BY KEY"),
3315
+ remove: z.array(z.string().min(1)).max(12).optional().describe("gradient keys to remove")
3316
+ }).shape,
3317
+ async (c, a) => ok("Brand graphics.", await data(c, "POST", `/management/projects/current/brand-kit/graphics`, { upsert: a.upsert, remove: a.remove }))
3318
+ ),
3240
3319
  def(
3241
3320
  "set_icon",
3242
3321
  "Set a Content sidebar icon",
3243
- `Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_NOTE}`,
3322
+ `Set or clear the sidebar icon of a page, a collection or a folder. ${STRUCTURE_FENCE}`,
3244
3323
  z.object({
3245
- kind: z.enum(["page", "collection", "folder"]),
3324
+ kind: z.enum(["page", "collection", "folder"]).describe(
3325
+ "what the id IS on this branch \u2014 VERIFIED, not assumed: an id is either a page or a collection, never both, and a mismatch is refused with 400 NAV_KIND_MISMATCH naming the kind to use. Call it again with that kind, which also clears the wrong-kind entry."
3326
+ ),
3246
3327
  id: z.string().min(1).describe("the page id, collection (content model) id, or folder id"),
3247
3328
  icon: z.string().min(1).nullable().describe("lucide icon name, e.g. 'file-text'; null clears it")
3248
3329
  }).shape,
@@ -3251,7 +3332,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3251
3332
  def(
3252
3333
  "delete_folder",
3253
3334
  "Delete a folder (its contents move up; nothing is deleted)",
3254
- `Delete a folder (its contents move up; nothing is deleted). NOTHING ELSE IS DELETED: its pages, collections and sub-folders move up to the folder's parent, in the same order. ${STRUCTURE_NOTE}`,
3335
+ `Delete a folder (its contents move up; nothing is deleted). NOTHING ELSE IS DELETED: its pages, collections and sub-folders move up to the folder's parent, in the same order. ${STRUCTURE_FENCE}`,
3255
3336
  z.object({ folderId: z.string().min(1).describe("folder id (from get_content_structure)") }).shape,
3256
3337
  async (c, a) => structureResult(await data(c, "DELETE", `/management/content-structure/folders/${encodeURIComponent(s(a.folderId))}`))
3257
3338
  ),
@@ -3453,7 +3534,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3453
3534
  def(
3454
3535
  "submit_conversion_receipt",
3455
3536
  "Record what the conversion codemod could and could not do",
3456
- "Hand BetterCMS the codemod's own account of a conversion run, so the coverage meter can say WHY a path is not declared instead of only that it is not. 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? }`, where `action` is one of `wrap-span` (wrap the literal in a `<span data-bcms-field=\u2026>`), `declare-attr` (add `data-bcms-field=\u2026` to the element at file:line), `bind-expression` (replace the expression with the framework's bcmsField helper), `declare-richtext` (bind the container with the richtext helper), `bind-data` (the literal comes from the data file at file:line \u2014 bind that field) or `manual` (with a one-sentence `why`). Apply every fix in the source, rerun the codemod, resubmit. `complete: true` is the only receipt that ends a conversion \u2014 do not report a site converted on anything 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, so convert against a brief this project actually returned. \u{1F534} SUBMIT THE `--forms` RECEIPT TOO, AS A SECOND CALL. A run of `npx @bettercms-ai/convert --forms` writes a receipt whose `paths` are all zero and whose account is in a `forms` block (`{ wired, alreadyWired, pending[{ id, name, reason }], notes, wiredForms[{ id, file }] }`). Write it to its own file (`--receipt forms-receipt.json`) and submit it here under the same `briefDigest`: it is stored BESIDE the binding receipt and never touches coverage, and the next release reads `wiredForms` to say which component renders each form. Then read `forms.pending` and publish every form `forms.notes` names in the Forms tab. Requires artifact:write, the same authority as set_binding_mode; on a workspace-wide connection pass `projectId`.",
3537
+ "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. Requires artifact:write, the same authority as set_binding_mode.",
3457
3538
  z.object({
3458
3539
  briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
3459
3540
  receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim.")
@@ -3689,8 +3770,8 @@ ${res.warnings.join("\n")}` : summary, res.data);
3689
3770
  def(
3690
3771
  "get_binding_report",
3691
3772
  "Check what on the live site is editable",
3692
- "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), `pagesInspected`, `bound` (elements carrying a binding), and `unmatched` \u2014 per page, each path with its kind and the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report and this answers pages 0, mode null, refreshRequired true. `refreshReason` says which of two states that is: `never-generated` \u2014 no report was ever written for this slot, and only a release BetterCMS itself builds and serves writes one, so a HEADLESS site never gets one and redeploying the same way will not change it (verify that site by fetching it); `outdated` \u2014 a report exists in an older shape, and the next release rewrites it. It certifies exactly one thing \u2014 that every non-empty field of every page 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 (null when unknown: runtime release, >500 routes, or a report older than this field). `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge` (the build ships BcmsDraftBridge), `draft-route` (a node runtime rendering drafts server-side) or `none`, in which case get_next_steps carries the recipe (playbook section 11); null means the report predates the field. `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: `{count, pages, measuredAt, routes:[{path, count, visible, buckets:[{tag, context, chars, nodes, samples}]}]}`, where `path` is the route (the empty string is the home page), `visible` its countable characters (ornaments, skip links and the platform badge excluded from both sides) and `buckets` where the unowned text is, 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. An author turning the editor's Unbound text control on posts a bare count of text NODES for the route they opened (no `visible`, no buckets); those rows are reported only for a build the release never measured, never summed with the release's characters. `skipReasons` says why pages were not inspected (`no-html`, `route-cap`, `authored-content` = the singleton lane declined the project, so NO route of it has a page), and `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. `unaddressable` is measured PER SLOT; on a promote-gated project pass slot:'current' to read the tree the editor frames. Pass `slot` ('current' or 'staging') to read the other tree; the default is the slot this project's releases land in.",
3693
- z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read; defaults to the one this project deploys to") }).shape,
3773
+ "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.",
3774
+ 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,
3694
3775
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
3695
3776
  ),
3696
3777
  def(
@@ -3727,13 +3808,13 @@ ${res.warnings.join("\n")}` : summary, res.data);
3727
3808
  def(
3728
3809
  "componentize_sections",
3729
3810
  "Turn this site's derived sections into components",
3730
- "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 (its `sectionType` family, category 'section', placeable on any page) 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. Call it with `dryRun: true` first \u2014 same receipt, nothing written. 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. Place those by hand or accept the second component knowingly. 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`. `copy` says WHO OWNS THE COPY. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` is the page-builder model \u2014 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. Run it again and it converts nothing and duplicates nothing. `sections` narrows a run to named groupKeys (`hero`, `group-fast`); a key the plan does not list on the pages you named is refused as `unknown-section` rather than quietly doing nothing. 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.",
3811
+ "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.",
3731
3812
  z.object({
3732
3813
  digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
3733
3814
  pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
3734
3815
  pages: z.array(z.string().min(1)).optional().describe("The same page filter under the name the dashboard uses. Unioned with pageIds; an EMPTY list is refused rather than treated as every page."),
3735
3816
  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."),
3736
- copy: z.enum(["bind", "instance"]).optional().describe("Who owns each section's copy: 'bind' (default) leaves it in the page fields; 'instance' moves it onto the placement and marks those fields origin: componentized."),
3817
+ 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."),
3737
3818
  dryRun: z.boolean().optional().describe("true = return the receipt without writing anything. Do this first."),
3738
3819
  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.")
3739
3820
  }).shape,
@@ -4447,7 +4528,7 @@ ${notes.join("\n")}` : summary, created);
4447
4528
  name: "create_components",
4448
4529
  config: {
4449
4530
  title: "Create many components in one call",
4450
- 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. " + SECTION_DOCTRINE,
4531
+ 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.",
4451
4532
  inputSchema: createComponentsInput.shape
4452
4533
  },
4453
4534
  handler: guard(
@@ -4508,7 +4589,7 @@ ${notes.join("\n")}` : summary, created);
4508
4589
  name: "compose_pages",
4509
4590
  config: {
4510
4591
  title: "Set many pages' block composition in one call",
4511
- 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. " + SECTION_DOCTRINE,
4592
+ 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.",
4512
4593
  inputSchema: composePagesInput.shape
4513
4594
  },
4514
4595
  handler: guard(
@@ -4814,7 +4895,10 @@ ${lines.join("\n")}`, found);
4814
4895
  // through the client's request plumbing — no bespoke SDK method per endpoint.
4815
4896
  ...lifecycleTools()
4816
4897
  ];
4817
- return defs.map(withProjectId);
4898
+ return [
4899
+ ...defs.filter((d) => !LISTING_TAIL.has(d.name)),
4900
+ ...defs.filter((d) => LISTING_TAIL.has(d.name))
4901
+ ].map(withProjectId);
4818
4902
  }
4819
4903
  function registerTools(server, deps) {
4820
4904
  const withElicit = {
@@ -6093,7 +6177,7 @@ in the dashboard \u2014 those render as an empty string until they are published
6093
6177
  "convert_site",
6094
6178
  {
6095
6179
  title: "Convert a site so nothing is left pending (guided)",
6096
- description: "Run the conversion end to end: pull the source, run @bettercms-ai/convert, apply every fix it lists as pending, rerun until nothing is pending, and finish only when submit_conversion_receipt returns complete and get_binding_report reads unmatched 0.",
6180
+ description: "Run the conversion end to end: pull the source, run @bettercms-ai/convert, apply every fix it lists as pending, rerun until nothing is pending, and finish only when submit_conversion_receipt returns complete and get_binding_report reads unmatched 0, coverage.pending empty and every route's unaddressable under 2%.",
6097
6181
  argsSchema: {
6098
6182
  projectId: z2.string().optional().describe("the project to convert; omit if this connection is scoped to one")
6099
6183
  }