@bettercms-ai/mcp 0.72.1 → 0.73.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
@@ -2540,6 +2540,7 @@ var STRUCTURE_PLAYBOOK_URI = "bettercms://playbook/structure";
2540
2540
  var STRUCTURE_DEFAULT_INSTRUCTION = `After you create collections or pages, organise them per the structure playbook: read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure (read-only), show the user its outline, then apply it with set_content_structure and the version it returned (the If-Match). A project that already has a structure keeps it: file only what you created, with move_to_folder, unless the user asks for a full re-organisation.`;
2541
2541
  var SKILLS_ROUTING_INSTRUCTION = "Before writing code in a repo that uses BetterCMS, explore the repo, then read the installed `bettercms` skill (`npx @bettercms-ai/install --project -y` adds it if missing): its table turns what you found into the one reference the task needs \u2014 read only that. Without the skill installed, read the same files as resources: bettercms://skill/bettercms, then bettercms://skill/<reference>.";
2542
2542
  var DOCK_UI_ROUTING_INSTRUCTION = "How fields and props look in the editor (groups, tabs, rows, icons, item names) is playbook \xA714 or the `field-ui` prompt: write it with update_content_model field* or update_component prop* plus ifMatch, read it back, and finish when get_next_steps reports no dock-* step.";
2543
+ var PUBLISH_HOLD_INSTRUCTION = "An item an author held back from site publishes (`publishHeldAt` set) stays out of whole-site publishes: do not publish it unless the user names it; publish_entries `release` includes it again.";
2543
2544
  var RECEIPT_FIRST_INSTRUCTION = "Start every project with get_next_steps: its `receipt` lists what is already done (`done`), what is still missing (`missing`, each item citing the count it reacted to) and the one `nextAction`. Work from that receipt instead of re-reading the site, call get_next_steps again after each batch of edits, and before you tell the user you are finished. On an imported site the gate is the definition of done: run `receipt.gate.nextAction`, read again, and repeat until `receipt.complete` is true. Record a gap you cannot close with editor_setup `exempt` only for an allowed reason that is true, with a note, never in prose.";
2544
2545
  var NEXT_STEPS_DESCRIPTION = `Call this FIRST on a project. Returns \`receipt\` \u2014 \`done\` (live release, bound elements, conversion coverage, composed pages, published components and Layout), \`missing\`, \`nextAction\` and \`notes\` (unjudged: unknown, not done) \u2014 plus \`data\`, the unfinished items by \`ruleId\` (skill finish-site lists them). Each item cites its count and may carry \`payload\` {rows, total}, at most 20 rows. \`receipt.complete\` is the setup gate's verdict and \`receipt.gate\` its numbers per built route. Run \`receipt.gate.nextAction\`, call it again after each batch; you are finished only when \`complete\` is true. \`organise-content\`: pages and collections are in no folder \u2014 read ${STRUCTURE_PLAYBOOK_URI}, call suggest_content_structure and, once the user agrees, set_content_structure.`;
2545
2546
  var LAYOUT_CHROME_INSTRUCTION = 'When the user asks to make the navigation, footer or other site chrome editable, or get_next_steps lists a `layout-*` or `chrome-duplicated` item: this is playbook \xA711 item 3. Chrome is the project Layout, never page fields: in the source, wrap each Section in `data-bcms-layout-section` (the slot, its base slug) and mark each value `data-bcms-layout-field="layout:<sections[slug].id>:<fieldId>"`, never `data-bcms-field` inside it, then push; the strip removes the page copies, never delete them by hand. On a site BetterCMS serves, publish_layout writes Layout VALUES into the live pages; a structure change waits for a Rebuild. Never place nav, footer or modal blocks or fields on a page: the Layout (or the site\'s own layout) draws chrome, so a page copy either duplicates it or renders nowhere (get_componentize_plan reports one as LAYOUT_CHROME).';
@@ -2898,6 +2899,7 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
2898
2899
  "apply_branch",
2899
2900
  "generate_image",
2900
2901
  "transform_image",
2902
+ "write_alt_text",
2901
2903
  // The ABM campaign lane — also hidden from the default listing (ABM_TOOLS below).
2902
2904
  "research_account",
2903
2905
  "build_abm_page",
@@ -2950,10 +2952,11 @@ var WEBHOOK_EVENTS = [
2950
2952
  "deployment.triggered"
2951
2953
  ];
2952
2954
  var NEW_TOOL_TEXT = {
2955
+ save_reusable_block: "Copy a page section (its layout, values and design) into Saved blocks in the section library, under `name`. The page is not changed; a block placed from Saved blocks is an independent section.",
2953
2956
  search_content: "Full-text search over the project's PUBLISHED pages and entries (the site-search index). Returns id, type ('page'|'entry'), title, slug and a snippet per hit; `type` narrows to one kind. Drafts are not indexed: find those with list_content_entries `where`. `meta.unavailable` means site search is off for this project.",
2954
2957
  discard_draft: "Throw away unpublished edits to an entry (`entryId`) or a page (`pageId`) \u2014 exactly one: the draft returns to exactly what is live and `pendingChanges` becomes false. A page resets its blockJson and its published Layout overrides. 409 no_published_version means it was never published (delete it instead). Pass ifMatch (optimisticVersion) to refuse if it changed since you read it.",
2955
2958
  give_feedback: "Tell the BetterCMS team about a tool that failed, a capability you needed and did not have, or docs that misled you. Opens a ticket for the team; nothing in the project changes. Name the `tool`, and paste the request and response into `context`. Never include secrets or personal data.",
2956
- publish_entries: "Publish or unpublish many entries and pages in ONE call (up to 100 ids of each; pages publish only). Needs a publish-enabled connection, and each item still passes its own gates (editorial stage, required fields). Nothing stops the run: the result lists what changed (`published` or `unpublished`) and every refusal in `failed` with its reason. A publish answers 202 with the routes being rebuilt.",
2959
+ publish_entries: "Publish or unpublish many entries and pages in ONE call (up to 100 ids of each; no page unpublish). Needs a publish-enabled connection, and each item still passes its own gates (editorial stage, required fields). Nothing stops the run: the result lists what changed (`published` or `unpublished`) and every refusal in `failed` with its reason. A publish answers 202 with the routes being rebuilt. `hold` keeps a live item's edits out of whole-site publishes, `release` includes them again; publishing a held item here ends its hold.",
2957
2960
  create_content_entries: "Create up to 200 DRAFT entries of one collection in ONE call, each {slug?, data} as create_content_entry takes it. Every row reports `created` (with id and slug) or `failed` (with the error), a failure does not stop the others, and `meta` counts them. A row with `externalKey` is safe to send again: its entry is `updated` (draft only), or `unchanged` when the row is the same. A row setting a developer-locked field fails (FIELD_LOCKED). Publish afterwards with publish_entries.",
2958
2961
  translate_entry: "Create an entry's `targetLocale` translation with AI (uses AI credits: 402/403 when out or not on the plan). Text and longtext fields are translated; rich text and documents are copied for you to translate; the rest is copied. The new entry is a DRAFT with `locale` set and `translationOf` = the source; `meta` lists translated and copied keys. `targetLocale` must be one of the project's locales; 409 locale_conflict means that translation exists, so edit it instead.",
2959
2962
  list_webhooks: "List the project's outgoing webhooks: name, url, events, whether active, and delivery health (total, failed, last attempt). Signing secrets are never returned.",
@@ -2966,7 +2969,8 @@ var NEW_TOOL_TEXT = {
2966
2969
  refresh_search_index: "Tell search engines and site search about published changes; costs nothing. action 'indexnow' submits `urls` (this site's own, up to 1000) to IndexNow, or every change since the last submission when omitted. action 'reindex' rebuilds the project's site-search index. 503 means the index is unreachable: try again later.",
2967
2970
  import_redirects: "Bulk-create redirects for a migration, from `rows` or from `text` in `format` (our CSV export, a Netlify `_redirects` file, or a vercel.json). Call it with `dryRun: true` FIRST: nothing is written and each row comes back `would-create`, `existing` or `skipped` with a reason. Rows get create_redirect's rules. Max 5000.",
2968
2971
  editor_setup: "Make the live build editable. A build imports only SEO/meta, media, brand and the models bcms-content.json declares; this derives the rest from the live release: pages, collections, forms, bindings, button and section components (when the authoring preference is components), Component Groups and the Content sidebar. Run it after an import or a rebuild, before the conversion brief. Idempotent: `ready` when it already ran for the live sha, else it starts (`running`; poll with `check: true` or get_deploy_status `editorSetup`). `state`: never or stale (run it), running, ready, failed. 409 = nothing is live yet: deploy first. Then get_next_steps.",
2969
- transform_image: "Edit an existing Media Library image with an instruction (e.g. 'remove the background', 'make it dusk'); the result is saved as a NEW asset and the original is kept (5 AI credits). Returns the new asset (id, url, altText)."
2972
+ transform_image: "Edit an existing Media Library image with an instruction (e.g. 'remove the background', 'make it dusk'); the result is saved as a NEW asset and the original is kept (5 AI credits). Returns the new asset (id, url, altText).",
2973
+ write_alt_text: "Alt text for a Media Library image (mediaId) or an https image URL (url), written from its pixels (1 AI credit). A suggestion: save it with update_media."
2970
2974
  };
2971
2975
  var DELETE_PAGE_RUNTIME_BUNDLE = " Or, with no pageId, `runtimeBundle`: 'report' lists the duplicate pages derived from a server app's own bundle (`app/` folders); 'apply' retires them and their empty folders.";
2972
2976
  var FIELD_LOCKED_NOTE = "A readOnly 'developer' field keeps its stored value: changing or omitting it without schema access is 403 FIELD_LOCKED.";
@@ -3025,7 +3029,8 @@ var SEO_KEY_TEXT = {
3025
3029
  indexnowConfig: "IndexNow on publish, minus URL patterns to leave out",
3026
3030
  searchConfig: "site search: on/off, the box's placeholder, AI answers"
3027
3031
  };
3028
- var VARIANT_GROUP_NOTE = "When the component belongs to a family of looks, `variantGroup` names the family and lists its looks.";
3032
+ var VARIANT_GROUP_NOTE = "`variantGroup`: the family of looks it belongs to, if any, and its looks.";
3033
+ var DETACHED_COPY_NOTE = "{overrides?}: a rich-text embed's separate-copy `fields` (playbook \xA75)";
3029
3034
  var UNPROPPED_COPY_NOTE = "A `warnings` entry UNPROPPED_COPY names copy no prop targets, which nobody can edit: add a prop with a `target` for each leaf it lists.";
3030
3035
  var UPDATE_PAGE_IF_MATCH_NOTE = "get_page's `optimisticVersion`: the write applies only at that version, and a 409 means the page changed since, so read it again. Send it whenever you pass blockJson, which REPLACES the whole composition.";
3031
3036
  var SLOT_MULTIPLE_NOTE = "'slot' with config.multiple holds a list [{componentId, overrides?}] (config.maxItems, default 12, at most 50): target.path 'props.children' on a section. V2 slots, 'table' mode 'types': playbook \xA75.";
@@ -3260,7 +3265,13 @@ var propUiObject = uiObject.extend({
3260
3265
  collapsible: z.boolean().optional(),
3261
3266
  layout: z.enum(["stack", "breakpoints"]).optional(),
3262
3267
  order: z.number().int().min(0).max(99).optional()
3263
- }).optional().describe("the group this prop (or sub-field) sits in. The FIRST member defines {id, label, kind?, icon?, collapsed?, description?, columns?, collapsible?, layout?, order?}; later ones send only {id}.")
3268
+ }).optional().describe("the group this prop (or sub-field) sits in. The FIRST member defines {id, label, kind?, icon?, collapsed?, description?, columns?, collapsible?, layout?, order?}; later ones send only {id}."),
3269
+ tab: z.strictObject({
3270
+ id: z.string().min(1).max(40),
3271
+ label: z.string().min(1).max(20).optional(),
3272
+ icon: uiObject.shape.icon,
3273
+ order: z.number().int().min(0).max(99).optional()
3274
+ }).optional().describe("TOP-LEVEL props only: the dock tab this prop sits in (a V2 kit schema `groups` entry: Content, Design, \u2026). The FIRST prop in a tab defines {id, label, icon?, order?}; later ones send only {id}. \u22648 tabs, labels unique; a group's members share one tab.")
3264
3275
  });
3265
3276
  var choiceOption = z.union([z.string(), z.object({ label: z.string(), value: z.string(), icon: z.string().optional(), description: z.string().optional() })]);
3266
3277
  var fieldShape = {
@@ -3279,7 +3290,7 @@ var fieldShape = {
3279
3290
  "choices when type is 'select'. A plain string, or {label, value, icon?, description?} when the stored value differs from what the editor shows (e.g. {label:'Sold out', value:'soldOut'}); icon a dock name, description one line \u2264140."
3280
3291
  ),
3281
3292
  config: z.record(z.string(), z.unknown()).optional().describe(
3282
- "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: text|number|date|image|link|richtext}, date {includeTime, timezone}, modular|slot {blockSlugs: ['quote','gallery'], minItems?, maxItems?} (slot: playbook \xA75) \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; limits: text/longtext {minLength,maxLength,pattern,patternMessage}, richtext {minLength,maxLength}, number {validation:{min,max}}, array {minItems,maxItems}, tags {min,max}, rating {min,max,step}"
3293
+ "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, repeater {minItems, maxItems} (the list's item limits; the editor shows 'max N' and stops Add there), array {itemType: text|number|date|image|link|richtext}, date {includeTime, timezone}, modular|slot {blockSlugs: ['quote','gallery'], minItems?, maxItems?} (slot: playbook \xA75) \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; limits: text/longtext {minLength,maxLength,pattern,patternMessage}, richtext {minLength,maxLength}, number {validation:{min,max}}, array {minItems,maxItems}, tags {min,max}, rating {min,max,step}"
3283
3294
  ),
3284
3295
  fields: z.array(z.lazy(() => fieldObject)).optional().describe(
3285
3296
  "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."
@@ -3450,7 +3461,8 @@ function toField(f) {
3450
3461
  return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
3451
3462
  }
3452
3463
  if (f.type === "repeater") {
3453
- return { ...base, type: "array", config: { zones: { repeatable: { fields: toFields(f.fields) } } } };
3464
+ const { minItems, maxItems } = f.config ?? {};
3465
+ return { ...base, type: "array", config: { zones: { repeatable: { fields: toFields(f.fields), ...minItems !== void 0 ? { minItems } : {}, ...maxItems !== void 0 ? { maxItems } : {} } } } };
3454
3466
  }
3455
3467
  if (f.type === "array" && f.config && typeof f.config === "object" && "zones" in f.config) {
3456
3468
  const zones = f.config.zones ?? {};
@@ -3483,8 +3495,8 @@ var UPDATE_MODEL_DESCRIPTION = "Edit a content model: rename it, change descript
3483
3495
  var FIELD_REMOVAL_NOTE = "top-level field keys (from get_content_model) to DELETE from this model with every entry's value for them; needs allowFieldRemoval: true. An unknown key refuses the whole call. Never for a rename: rename the label instead.";
3484
3496
  var ALLOW_FIELD_REMOVAL_NOTE = "true = the user accepts that removeFields discards those fields' data in every entry. There is no dry run on this route: confirm first.";
3485
3497
  var MODEL_IF_MATCH_NOTE = "optimisticVersion from get_content_model (with pageId: get_page backingModel). The write applies only at that version; a 409 version-conflict means it changed since: read it again and re-apply, never retry blindly. REQUIRED with field*, override or a fieldset kind/tab/icon/collapsed (428 without); otherwise, omitted, the last write wins.";
3486
- var COMPONENT_IF_MATCH_NOTE = "optimisticVersion from get_component. The write applies only at that version; a 409 version-conflict means it changed since: read it again and re-apply. REQUIRED with prop*, override or a prop ui icon/itemLabel/group (428 without).";
3487
- var PROP_UI_ARG_NOTE = "top-level prop key \u2192 ui patch, the same vocabulary as a prop's `ui`: each key merges, a null key clears it, null clears the whole ui";
3498
+ var COMPONENT_IF_MATCH_NOTE = "optimisticVersion from get_component. The write applies only at that version; a 409 version-conflict means it changed since: read it again and re-apply. REQUIRED with prop*, override or a prop ui icon/itemLabel/group/tab (428 without).";
3499
+ var PROP_UI_ARG_NOTE = "top-level prop key \u2192 ui patch, the same vocabulary as a prop's `ui` (group and tab included): each key merges, a null key clears it, null clears the whole ui. Patches keep every other prop as stored; sending `props` instead REPLACES the whole array, so echo each prop's stored `ui` (its `ui.group` AND `ui.tab`) or the prop leaves its tab";
3488
3500
  var OVERRIDE_NOTE = "replace chrome a person set (otherwise 409 ui_human_owned names those paths). Send it only on the user's word.";
3489
3501
  var FIELD_UI_ARG_NOTE = "path \u2192 ui patch, a field's `ui` vocabulary: each key merges, a null key clears it, null clears the whole ui";
3490
3502
  var MODEL_UPDATE_KEYS = [
@@ -3713,7 +3725,7 @@ function buildToolDefs(deps) {
3713
3725
  "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, variant? (hosted pages; else a Button component), size? sm|md|lg, fullWidth?, arrow? boolean, icon? ui.icon name, newTab? boolean, rel? nofollow|sponsored|ugc}; 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 hold ANY block; slider {slides:[{id,children}]}; tabs {tabs:[{id,label,children}]}; collection {cardComponentId?, source? {modelId}, limit?, filters?, sort?, pinnedIds?, excludeCurrent?}: a collection's published entries as cards (every prop: skill content-modeling)"
3714
3726
  ),
3715
3727
  style: z.record(z.string(), z.unknown()).optional().describe(
3716
- "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, border (all four sides, in the text colour), hideOn [mobile|tablet|desktop], never all three. A real marketing band is a `section` block carrying bg + padding + contentWidth."
3728
+ "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, border (all four sides, in the text colour), hideOn [mobile|tablet|desktop] (all three hides it on the page: kept in the draft, never published). A real marketing band is a `section` block carrying bg + padding + contentWidth."
3717
3729
  ),
3718
3730
  window: windowArg.nullable().optional().describe(WINDOW_NOTE)
3719
3731
  });
@@ -4092,6 +4104,7 @@ function buildToolDefs(deps) {
4092
4104
  ),
4093
4105
  propUi: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional().describe(PROP_UI_ARG_NOTE),
4094
4106
  propGroup: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional().describe("prop key \u2192 its ui.group ({id, label?, kind?, icon?, collapsed?}), null takes it out"),
4107
+ propTab: z.record(z.string(), z.record(z.string(), z.unknown()).nullable()).optional().describe("prop key \u2192 its ui.tab ({id, label?, icon?, order?}), the V2 schema group it sits in. The FIRST top-level prop in a tab defines {id, label, icon?, order?}, later ones send {id}; \u22648 tabs; one group's members share one tab. null takes it out"),
4095
4108
  propLabel: z.record(z.string(), z.string().min(1).max(255)).optional().describe("prop key \u2192 label"),
4096
4109
  propHelpText: z.record(z.string(), z.string().max(200).nullable()).optional().describe("prop key \u2192 help text, null clears it"),
4097
4110
  override: z.boolean().optional().describe(OVERRIDE_NOTE),
@@ -4120,7 +4133,8 @@ function buildToolDefs(deps) {
4120
4133
  componentId: z.string().min(1).describe("component id (from list_components)")
4121
4134
  });
4122
4135
  const getComponentInput = z.object({
4123
- componentId: z.string().min(1).describe("component id (from list_components)")
4136
+ componentId: z.string().min(1).describe("component id (from list_components)"),
4137
+ detachedCopy: z.object({ overrides: z.record(z.string(), z.unknown()).optional() }).optional().describe(DETACHED_COPY_NOTE)
4124
4138
  });
4125
4139
  const publishComponentsInput = z.object({
4126
4140
  componentIds: z.array(z.string().min(1)).min(1).max(100).describe("component ids (from list_components)")
@@ -4428,6 +4442,13 @@ ${d.outline}` : "Proposed Content structure.", d);
4428
4442
  z.object({ redirectId: z.string().min(1) }).shape,
4429
4443
  async (c, a) => ok("Deleted redirect.", await data(c, "DELETE", `/management/redirects/${s(a.redirectId)}`))
4430
4444
  ),
4445
+ def(
4446
+ "save_reusable_block",
4447
+ "Save a section as a reusable block",
4448
+ NEW_TOOL_TEXT.save_reusable_block,
4449
+ z.object({ pageId: z.string().min(1), blockId: z.string().min(1).describe("a top-level section id (get_page)"), name: z.string().min(1).describe("80 characters at most; a longer one is refused as SAVED_BLOCK_NAME_TOO_LONG") }).shape,
4450
+ async (c, a) => ok("Saved the section as a reusable block.", await data(c, "POST", `/management/saved-blocks`, { pageId: a.pageId, blockId: a.blockId, name: a.name }))
4451
+ ),
4431
4452
  def(
4432
4453
  "get_seo",
4433
4454
  "Get site SEO",
@@ -5022,14 +5043,14 @@ ${d.outline}` : "Proposed Content structure.", d);
5022
5043
  "Publish or unpublish many entries and pages",
5023
5044
  NEW_TOOL_TEXT.publish_entries,
5024
5045
  z.object({
5025
- action: z.enum(["publish", "unpublish"]).describe("take the items live, or back to draft"),
5046
+ action: z.enum(["publish", "unpublish", "hold", "release"]).describe("live, back to draft, or hold/release from site publishes"),
5026
5047
  entryIds: z.array(z.string().min(1)).max(100).optional().describe("entry ids (from list_content_entries)"),
5027
- pageIds: z.array(z.string().min(1)).max(100).optional().describe("page ids (from list_pages); publish only")
5048
+ pageIds: z.array(z.string().min(1)).max(100).optional().describe("page ids (from list_pages); not for unpublish")
5028
5049
  }).shape,
5029
5050
  async (c, a) => {
5030
5051
  const receipt = await data(c, "POST", `/management/publish`, { action: a.action, entryIds: a.entryIds, pageIds: a.pageIds });
5031
5052
  const outcome = receipt?.outcome;
5032
- return ok(a.action === "publish" ? publishedLines(outcome, "Publish receipt.").join("\n") : "Unpublish receipt.", receipt);
5053
+ return ok(a.action === "publish" ? publishedLines(outcome, "Publish receipt.").join("\n") : a.action === "unpublish" ? "Unpublish receipt." : "Hold receipt.", receipt);
5033
5054
  }
5034
5055
  ),
5035
5056
  def(
@@ -5141,6 +5162,13 @@ ${d.outline}` : "Proposed Content structure.", d);
5141
5162
  }).shape,
5142
5163
  async (c, a) => ok("Edited image (a new asset).", await data(c, "POST", `/management/ai/image/transform`, { mediaId: a.mediaId, prompt: a.prompt, altText: a.altText, folderId: a.folderId }))
5143
5164
  ),
5165
+ def(
5166
+ "write_alt_text",
5167
+ "Write alt text with AI",
5168
+ NEW_TOOL_TEXT.write_alt_text,
5169
+ z.object({ mediaId: z.string().min(1).optional().describe("the image (from list_media)"), url: z.string().optional().describe("or an https image URL") }).shape,
5170
+ async (c, a) => ok("Alt text suggestion.", await data(c, "POST", `/management/ai/alt-text`, a.url ? { url: a.url } : { mediaId: a.mediaId }))
5171
+ ),
5144
5172
  def(
5145
5173
  "list_activity",
5146
5174
  "List project activity",
@@ -5509,6 +5537,8 @@ ${d.outline}` : "Proposed Content structure.", d);
5509
5537
  language: p.language,
5510
5538
  pageType: p.pageType,
5511
5539
  status: p.status,
5540
+ // Held back from whole-site publishes: only when set, so an unheld list stays the same size.
5541
+ ...p.publishHeldAt ? { publishHeldAt: p.publishHeldAt } : {},
5512
5542
  fields: p.fields
5513
5543
  }))
5514
5544
  );
@@ -6019,11 +6049,18 @@ ${notes.join("\n")}` : summary, created);
6019
6049
  name: "get_component",
6020
6050
  config: {
6021
6051
  title: "Get a component (with its blockJson)",
6022
- description: "Get one component by id INCLUDING its blockJson tree and props (with any stored `ui` authoring chrome on them; `stale: true` = no placement renders it). Read this before update_component so you keep the existing blocks, and after declaring field UI as the receipt that it landed \u2014 a prop absent here was not stored. " + VARIANT_GROUP_NOTE,
6052
+ description: "One component with its blockJson and props (stored `ui` on them; `stale: true` = no placement renders it). Read it before update_component to keep its blocks, and after declaring field UI: a prop absent here was not stored. " + VARIANT_GROUP_NOTE,
6023
6053
  inputSchema: getComponentInput.shape
6024
6054
  },
6025
6055
  handler: guard(
6026
6056
  async (args) => withClient(async (client) => {
6057
+ if (args.detachedCopy) {
6058
+ const res = await client.fetchJSON(
6059
+ client.url(`/management/components/${encodeURIComponent(args.componentId)}/detached-copy`),
6060
+ { method: "POST", body: JSON.stringify(args.detachedCopy) }
6061
+ );
6062
+ return ok(`A separate copy of '${res.data.name}': store \`fields\` on the embed.`, res.data);
6063
+ }
6027
6064
  const cmp = await client.getComponent(args.componentId);
6028
6065
  return ok(`Component '${cmp.name}' (${cmp.blockJson.length} block(s)).`, cmp);
6029
6066
  })
@@ -6549,6 +6586,16 @@ component). A list of several item shapes on a component is a \`table\` prop wit
6549
6586
  \`config.mode: 'types'\` and \`config.itemTypes: [{ name, label?, fields }]\`, rows
6550
6587
  \`[{ _key?, _type: '<name>', ...values }]\`.
6551
6588
 
6589
+ A component inside a \`richtext\` value is \`{ _type: 'bcmsBlock', _key, schemaKey: 'component',
6590
+ schemaLabel?, fields: { componentId, overrides?, variant? } }\` and FOLLOWS the component: publishing
6591
+ it changes this place. A SEPARATE COPY stops following it: \`fields: { componentId, detached: true,
6592
+ props, blocks, sourcePublishedAt, variant? }\`, exactly as \`get_component { componentId,
6593
+ detachedCopy: { overrides } }\` returns them. It renders its own \`blocks\`; \`componentId\` stays so
6594
+ it can be linked again (write the linked shape), and a \`sourcePublishedAt\` older than the
6595
+ component's \`lastPublishedAt\` (every publish; \`publishedAt\` is the first) means the shared
6596
+ version changed since. Refused by name:
6597
+ \`component_copy_invalid\`, \`component_copy_not_frozen\`.
6598
+
6552
6599
  ## 5b. NEVER MODEL SEO. It is native.
6553
6600
 
6554
6601
  Do not create \`seoTitle\`, \`metaTitle\`, \`metaDescription\`, \`ogImage\`, \`canonicalUrl\`,
@@ -7187,8 +7234,11 @@ conversion is yours to write.
7187
7234
  markup inside a \`text\` field.
7188
7235
  **Prose MIGRATED from another CMS needs one extra check.** Every block's \`_type\` must be one
7189
7236
  this platform stores \u2014 \`block\`, \`table\` (\`{_type:"table", rows:[{_key, cells:[{_key,
7190
- content:[...blocks], header?}]}]}\`; Sanity's row of plain strings, \`rows:[{cells:["a","b"]}]\`,
7191
- is admitted too), \`bcmsCode\`, \`bcmsDivider\`, \`bcmsMention\`, or \`bcmsBlock\` carrying a
7237
+ content:[...blocks], header?, group?}], group?}], caption?, notes?, rowGroups?, columnGroups?}\`,
7238
+ groups \`[{_key, label}]\`; Sanity's row of plain strings, \`rows:[{cells:["a","b"]}]\`,
7239
+ is admitted too), \`bcmsCode\`, \`bcmsDivider\`, \`bcmsMention\`, \`pullQuote\`
7240
+ (\`{quote, name, role?, link?}\`), \`keyTakeaways\` (\`{title?, points:[...strings]}\`,
7241
+ title at most 40 characters, at most 8 points), or \`bcmsBlock\` carrying a
7192
7242
  \`schemaKey\` \u2014 and a foreign node (a Sanity image, \`{_type:"image", asset:{_ref}}\`, for one)
7193
7243
  is not merely unrendered: it has no component here, so it
7194
7244
  vanishes from the HTML every delivery surface reads while the stored value still looks whole.
@@ -7354,8 +7404,13 @@ them \u2014 except on a \`slot\`, whose children belong to whichever component f
7354
7404
  cannot be resolved at save time. \`update_component\` REPLACES the whole \`props\` array, so echo
7355
7405
  back each prop's stored \`ui\` or you delete it along with the prop. Props have no fieldsets, so a
7356
7406
  prop joins a group with \`ui.group\`: the FIRST prop of a group defines \`{ id, label, kind?, icon?,
7357
- collapsed? }\` (kind \`group\` or \`row\`, never \`tab\`), and every later prop sends \`{ id }\` only. A
7358
- FIELD never takes \`ui.group\`; it joins a fieldset with \`fieldsetId\`.
7407
+ collapsed? }\` (kind \`group\` or \`row\`; a tab is \`ui.tab\`, never a group kind), and every later prop
7408
+ sends \`{ id }\` only. A prop joins a TAB with \`ui.tab\` the same way: the FIRST top-level prop in a tab
7409
+ defines \`{ id, label, icon?, order? }\`, later props send \`{ id }\`; at most 8 tabs, labels \u226420 characters
7410
+ and unique; top-level props only (never a sub-field); all members of one \`ui.group\` share one tab. A V2
7411
+ kit entry's schema \`groups\` and each field's \`group\` map 1:1 to \`ui.tab\`, so its \`design\` group is a
7412
+ tab named Design holding the theme, sticky and similar props. A FIELD never takes \`ui.group\` or
7413
+ \`ui.tab\`; it joins a fieldset with \`fieldsetId\`.
7359
7414
 
7360
7415
  \`\`\`jsonc
7361
7416
  { "key": "slides", "label": "Slides", "type": "table",
@@ -7424,13 +7479,13 @@ Importance orders a screen: required fields first, then array order.
7424
7479
  | A star rating | \`type: 'rating'\`: a number from 0 to \`config.max\` (5), \`config.step\` 1 or 0.5 (\`rating_value_invalid\`) |
7425
7480
  | A code snippet | \`type: 'code'\`: \`{ language?, code }\` or a string; \`config.language\` default, \`config.languages\` allowlist (\`code_value_invalid\`) |
7426
7481
  | "Add question" instead of "Add item" | \`ui.itemLabel: 'Question'\` (V2 \`itemNoun\`; there is no itemNoun key) |
7427
- | The same section as a component (the dock edits a page section through its props) | the same keys on its \`props\`: \`type: 'object'\` with \`config.fields\` (\`group\` and \`table\` too, 10 levels), \`datetime\`, \`slug\`, \`email\`, \`phone\`, \`json\`, \`location\`, \`tags\`, \`rating\`, \`code\`, plus the two CMS-item types (\`reference\`, \`multi-reference\`: \`config.contentModelId\` or \`polymorphic\` + \`allowedModelSlugs\`; page reads hydrate them, a text slot shows the title), and every key above except fieldsets (props group with \`ui.group\`) |
7482
+ | The same section as a component (the dock edits a page section through its props) | the same keys on its \`props\`: \`type: 'object'\` with \`config.fields\` (\`group\` and \`table\` too, 10 levels), \`datetime\`, \`slug\`, \`email\`, \`phone\`, \`json\`, \`location\`, \`tags\`, \`rating\`, \`code\`, plus the two CMS-item types (\`reference\`, \`multi-reference\`: \`config.contentModelId\` or \`polymorphic\` + \`allowedModelSlugs\`; page reads hydrate them, a text slot shows the title), and every key above except fieldsets (props group with \`ui.group\` and tab with \`ui.tab\`) |
7428
7483
 
7429
7484
  An unknown \`openMode\` is refused with \`openMode_invalid\`, one on a leaf \`openMode_leaf\`; a bad \`config.scale\` with \`scale_invalid\`; more than 10 nested field levels with \`field_depth_exceeded\`. update_field sets and clears (null) helpMore, advanced, recommendedMax and openMode on an existing field.
7430
7485
 
7431
7486
  **Tabs.** A \`kind: 'tab'\` fieldset is a tab:
7432
7487
  - at most **3** tabs, names at most 20 characters and unique;
7433
- - top-level fields only: tabs never nest and props never take them;
7488
+ - top-level fields only: tabs never nest (a component's props take tabs through \`ui.tab\`, its own rules);
7434
7489
  - untabbed fields stay PINNED above the strip;
7435
7490
  - a group or row fieldset joins a tab with \`tab: '<tab id>'\`; groups in a non-first tab default open;
7436
7491
  - with fewer than 2 non-empty tabs no strip renders, so a tab for one fieldset is noise.
@@ -7449,7 +7504,7 @@ An unknown \`openMode\` is refused with \`openMode_invalid\`, one on a leaf \`op
7449
7504
  \`fieldLabel\`, \`fieldHelpText\` (keyed by dotted key path, \`'hero.title'\`) and \`fieldsets\`. A page's
7450
7505
  schema: the same call with \`pageId\` instead of \`modelId\`, and the version from \`get_page\`'s
7451
7506
  \`backingModel\`.
7452
- - A component's props: \`update_component\` with \`propUi\`, \`propGroup\`, \`propLabel\`, \`propHelpText\` (keyed by prop key).
7507
+ - A component's props: \`update_component\` with \`propUi\`, \`propGroup\`, \`propTab\`, \`propLabel\`, \`propHelpText\` (keyed by prop key).
7453
7508
  Name props from the code (\`plan.cents\` \u2192 Cents); help text guides ("Whole number, no currency symbol."), never repeats
7454
7509
  the value; repeated cards are one \`table\` prop. An icon (\`config.media: "icon"\`) is content: group it, never prune it.
7455
7510
  They write the DRAFT props; no publish is needed for the editor to show them.
@@ -7462,6 +7517,8 @@ An unknown \`openMode\` is refused with \`openMode_invalid\`, one on a leaf \`op
7462
7517
  - **409** \`ui_human_owned\` \u2014 a person shaped those paths. Leave them, or send \`override: true\`, and
7463
7518
  only when the user said so.
7464
7519
  - \`tab_unknown\`, \`tab_nested\`, \`tab_props\`, \`tab_max\`, \`tab_name\`, \`tab_depth\` \u2014 the tab rules above.
7520
+ - \`prop_tab_unknown\`, \`prop_tab_redefined\`, \`prop_group_split_tabs\` \u2014 a prop's \`ui.tab\` rules (\`tab_max\`,
7521
+ \`tab_name\` and \`tab_nested\` too); \`tab_props\` is a \`ui.group\` with \`kind: 'tab'\` \u2014 use \`ui.tab\`.
7465
7522
  - \`fieldset_unknown\` / \`fieldset_orphan\` \u2014 a \`fieldsetId\` naming no fieldset, or a list that drops a
7466
7523
  fieldset its fields still name.
7467
7524
  - \`row_member_type\`, \`row_member_count\`, \`row_member_control\` \u2014 the row rule.
@@ -7587,7 +7644,7 @@ shape, then ask me, once and batched). Icon names are in bettercms://icons/dock.
7587
7644
  - fields that exist: \`update_content_model\` fieldUi / fieldFieldset / fieldOrder / fieldLabel /
7588
7645
  fieldHelpText by field path, plus \`fieldsets\`. A singleton page: send \`pageId\` instead of
7589
7646
  modelId, with its backingModel's version;
7590
- - component props: \`update_component\` propUi / propGroup / propLabel / propHelpText;
7647
+ - component props: \`update_component\` propUi / propGroup / propTab / propLabel / propHelpText;
7591
7648
  - a NEW field: ui / helpText / showIf in the same \`add_field\` call, with \`after\`.
7592
7649
  A 409 \`ui_human_owned\` names chrome a person set: leave it, or ask me and send \`override: true\`
7593
7650
  only on my word.
@@ -8248,7 +8305,7 @@ function buildServer(deps) {
8248
8305
  // one definition of done; without this an agent converts the page it landed on and stops.
8249
8306
  instructions: RECEIPT_FIRST_INSTRUCTION + " When the user asks to make a site or all of its pages editable, to convert it, or to bind its fields: this is playbook \xA713. Read `bettercms://playbook/schema` \xA713, call get_binding_report and get_conversion_brief { complete: true }, convert EVERY route the brief lists, and finish only when get_binding_report shows coverage.pending empty on every route \u2014 not when the first page works. On a workspace-wide connection pass projectId on every call; never ask the user to re-scope the connection. " + LAYOUT_CHROME_INSTRUCTION + " " + CTA_BUTTON_INSTRUCTION + " When the user asks to componentize the whole site, to turn every section into a component, or to build a component library from the site: this is playbook \xA712. Read `bettercms://playbook/schema` \xA712, start with get_site_composition, and use the batch tools \u2014 componentize_sections, compose_pages, update_layout with `commands`, publish_components \u2014 rather than one call per component. Finish with get_site_composition and tell the user what the platform does not model (cookie banners, modals, breadcrumbs, pagination) and which components still need the owner's approval in the dashboard before they can be published. " + // The default after authoring (the structure standard): organise what you made. Same
8250
8307
  // sentence as the hosted connector's MCP_INSTRUCTIONS.
8251
- STRUCTURE_DEFAULT_INSTRUCTION + " " + SKILLS_ROUTING_INSTRUCTION + " " + DOCK_UI_ROUTING_INSTRUCTION
8308
+ STRUCTURE_DEFAULT_INSTRUCTION + " " + SKILLS_ROUTING_INSTRUCTION + " " + DOCK_UI_ROUTING_INSTRUCTION + " " + PUBLISH_HOLD_INSTRUCTION
8252
8309
  }
8253
8310
  );
8254
8311
  server.registerResource(