@bettercms-ai/mcp 0.68.2 → 0.70.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
@@ -27,7 +27,16 @@ var COMPONENT_PROP_TYPES = [
27
27
  "group",
28
28
  "table",
29
29
  "slot",
30
- "form"
30
+ "form",
31
+ // The seven below each store what the same-named content-model FIELD stores (`icon` and
32
+ // `multiselect` have no field twin; see the value notes on ComponentPropDef.type).
33
+ "link",
34
+ "button",
35
+ "date",
36
+ "color",
37
+ "file",
38
+ "icon",
39
+ "multiselect"
31
40
  ];
32
41
 
33
42
  // ../types/src/layout-lucide-icons.ts
@@ -2510,7 +2519,7 @@ function allKeys(fields) {
2510
2519
  })
2511
2520
  ]);
2512
2521
  }
2513
- var CLEARABLE = ["helpText", "showIf", "ui", "placement"];
2522
+ var CLEARABLE = ["helpText", "showIf", "ui", "placement", "readOnly", "lockReason"];
2514
2523
  var SETTABLE = ["label", "required", ...CLEARABLE];
2515
2524
  function mergeConfig(current, patch) {
2516
2525
  const out = { ...current ?? {} };
@@ -2548,7 +2557,7 @@ function planFieldEdit(model, edit, callerIfMatch) {
2548
2557
  }
2549
2558
  const changes = SETTABLE.filter((k) => edit[k] !== void 0);
2550
2559
  if (!retype && rename === void 0 && edit.config === void 0 && changes.length === 0) {
2551
- return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, type or key." };
2560
+ return { ok: false, error: "Nothing to change: pass label, helpText, required, config, showIf, ui, placement, readOnly, lockReason, type or key." };
2552
2561
  }
2553
2562
  const echo = helpTextEcho(edit.helpText, { label: edit.label ?? current.label, defaultValue: current.defaultValue });
2554
2563
  if (echo) return { ok: false, error: echo };
@@ -2601,6 +2610,7 @@ var LISTING_TAIL = /* @__PURE__ */ new Set([
2601
2610
  "extract_component",
2602
2611
  "list_extraction_candidates",
2603
2612
  "create_deploy_upload",
2613
+ "import_redirects",
2604
2614
  "set_authoring_preference",
2605
2615
  "set_media_delivery",
2606
2616
  "delete_form_submission",
@@ -2663,8 +2673,9 @@ function listsTool(name, sets) {
2663
2673
  }
2664
2674
 
2665
2675
  // src/tool-text.ts
2666
- var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page a 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section. To change a field that exists, use update_field.";
2676
+ var ADD_FIELD_DESCRIPTION = "Append ONE field to a content model (`modelId`) or to a page's schema (`pageId`) \u2014 additive, never overwrites; a key already in use is refused. For a section/zone add ONE 'group' (fixed block) or 'repeater' (repeating list) carrying its child `fields`, not the zone's inner fields as separate top-level fields. Pass `after` to insert it after an existing top-level field instead of at the end; `ui`, `helpText`, `showIf`, `placement` and `fieldsetId` declare how it presents in the editor \u2014 read them back with get_content_model or get_page. On a page a 'group' is stored as type 'array' with `config.zones.nonRepeatable`: entry and delivery data carry `<key>.nonRepeatable.<sub>`, while data-bcms-field paths are `<key>.<sub>`. Schema only \u2014 never places a section; use componentize_sections to turn a group into a section. `multiselect` stores a string[] of `config.options` (`allowCustom` for tags); `icon` is a BetterCMS icon name or an uploaded icon. To change a field that exists, use update_field.";
2667
2677
  var MOVE_STAGE_DESCRIPTION = "Move a content entry (`entryId`) or a page (`pageId`) to another editorial stage \u2014 this is how you take it OUT of review so it can be published. `workflowStage` is a stage key from get_workflow_board; pass `fromStage` to 409 if someone moved it first. Moving into the publish-gate stage is an approval and needs a publish-enabled connection. Publish afterwards with update_content_entry or update_page status:'published' \u2014 approving and publishing are deliberately two separate calls.";
2678
+ var WINDOW_NOTE = "{from?, until?}, ISO 8601 with offset: shown only between the two instants; null clears (skill publishing).";
2668
2679
  var WEBHOOK_EVENTS = [
2669
2680
  "content.publish",
2670
2681
  "content.unpublish",
@@ -2679,7 +2690,7 @@ var NEW_TOOL_TEXT = {
2679
2690
  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.",
2680
2691
  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.",
2681
2692
  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.",
2682
- 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 both. Publish afterwards with publish_entries.",
2693
+ 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.",
2683
2694
  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.",
2684
2695
  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.",
2685
2696
  create_webhook: "Create an outgoing webhook: BetterCMS POSTs each subscribed event to your https `url`, signed with HMAC-SHA256 in X-BetterCMS-Signature. The response carries the signing `secret` ONCE: store it now. `test: true` also sends a sample delivery. Payloads carry ids and metadata, never the content: fetch it from the API. A receiver that writes back on content.updated loops unless it skips its own writes.",
@@ -2689,12 +2700,23 @@ var NEW_TOOL_TEXT = {
2689
2700
  search_docs: "Search the BetterCMS documentation. Returns the best pages (title, url, breadcrumbs, snippet). Use it before guessing how a feature works; `meta.unavailable` means docs search is down right now.",
2690
2701
  generate_image: "Generate an image with AI and add it to the Media Library (5 AI credits; free with the workspace's own Together key). `width`/`height` are 256-1440 in multiples of 16 (default 1024x768). Returns the new asset (id, url, altText): use the url as an 'image' field value. Always set `altText`.",
2691
2702
  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.",
2703
+ 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.",
2692
2704
  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.",
2693
2705
  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)."
2694
2706
  };
2695
2707
  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.";
2708
+ var FIELD_LOCKED_NOTE = "A readOnly 'developer' field keeps its stored value: changing or omitting it without schema access is 403 FIELD_LOCKED.";
2696
2709
  var PARAM_TEXT = {
2710
+ dryRun: "validates and reports what would be written; writes nothing",
2711
+ externalKey: "id in the source system; sent again, it updates that entry's draft or leaves it unchanged",
2712
+ upsertSource: "source URL, kept with the entry",
2713
+ upsertRunId: "your run's id, kept with the entry",
2714
+ externalKeyFilter: "exact externalKey an entry was created with",
2715
+ readOnly: "'developer' = the value and this lock change only with schema access (needs lockReason); true = an editor marker",
2716
+ lockReason: "which system owns the value (\u2264 200 chars)",
2717
+ lockClears: "as on add_field; null clears it",
2697
2718
  helpText: "shown under the field: its purpose, where it shows, any constraint; never its label or value. Omitted: written for you.",
2719
+ fromBuiltin: "a builtin:* id from list_components: copies it into the project; fields you send win",
2698
2720
  describes: "for editors: what it is and holds; omitted: written for you",
2699
2721
  runtimeBundle: "instead of pageId: the server-app bundle cleanup",
2700
2722
  pageRef: "page id or path ('/de/about'); a bare slug only when one page has it",
@@ -2719,14 +2741,14 @@ var PARAM_TEXT = {
2719
2741
  confirmedInput: "true only after the user explicitly approved adding this custom input (`ui.input`); else setting or changing one is refused input_needs_confirmation. Removing one needs no approval"
2720
2742
  };
2721
2743
  var INPUT_CONFIRMED_HEADER = "x-bcms-confirmed-input";
2722
- var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant?, size?, arrow?, icon?, newTab?} (arrow:false, icon:null turn one off): never a text field beside a link field.";
2723
- var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui or placement (null clears those). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename. A page's label + link pair (primaryCtaText + primaryCtaHref) becomes one button with `mergeHref`.";
2744
+ var BUTTON_FIELD_NOTE = "'button' is ONE call to action, value {label, href, variant?, size?, arrow?, icon?, newTab?, rel?} (arrow:false, icon:null turn one off): never a text field beside a link field.";
2745
+ var UPDATE_FIELD_DESCRIPTION = "Change ONE existing field of a content model (`modelId`) or of a page's schema (`pageId`), addressed by `path`: its key, or dot-separated keys through groups and repeaters ('hero.title'). Set its label, helpText, required, validation `config` (merged by key; null deletes one), showIf, ui, placement, readOnly or lockReason (null clears those). The rest of the schema is written back unchanged at the version read; a 409 means it changed since, so read again. `type` changes how every stored value is read and needs allowRetype: true. `key` renames it: stored values stay under the old key, so the route answers 409 with the entry count unless allowFieldRemoval: true discards them \u2014 migrate the values first. Ask the user before a retype or a rename. A page's label + link pair (primaryCtaText + primaryCtaHref) becomes one button with `mergeHref`.";
2724
2746
  var GET_SEO_DESCRIPTION = "Get the connected project's site-wide SEO settings \u2014 every key update_seo writes: seoDefaults (metaTitle, metaDescription, ogImage, twitterHandle, og, twitter, collectionSchemaTypes), siteSchema (the site's JSON-LD), robotsConfig, sitemapConfig, rssConfig, indexnowConfig and searchConfig.";
2725
2747
  var UPDATE_SEO_DESCRIPTION = "Set the connected project's site-wide SEO defaults and config. Each key you send is REPLACED whole (not deep-merged), so read get_seo and send the complete object. `seoDefaults` applies to every page that does not override it, and its `collectionSchemaTypes` maps a collection's model id to the schema.org type of its entries. `siteSchema` is the site's own JSON-LD (Organization/WebSite, emitted on the home page). `robotsConfig`, `sitemapConfig` and `rssConfig` shape robots.txt, sitemap.xml and the feed; `indexnowConfig` pings search engines on publish; `searchConfig` is site search. A key the plan does not include answers 403 FEATURE_LOCKED. Rebuilds the site.";
2726
2748
  var SEO_KEY_TEXT = {
2727
2749
  seoDefaults: "site-wide meta defaults every page inherits",
2728
2750
  siteSchema: "the site's JSON-LD: an object or an array of objects",
2729
- robotsConfig: "robots.txt: 'auto', or 'custom' with its text",
2751
+ robotsConfig: "robots.txt: 'auto' (`disallow` names AI crawlers to block), or 'custom' with its text",
2730
2752
  sitemapConfig: "sitemap.xml: generated ('auto') or 'custom', defaults and extra URLs",
2731
2753
  rssConfig: "the RSS feed on or off",
2732
2754
  indexnowConfig: "IndexNow on publish, minus URL patterns to leave out",
@@ -2735,6 +2757,8 @@ var SEO_KEY_TEXT = {
2735
2757
  var VARIANT_GROUP_NOTE = "When the component belongs to a family of looks, `variantGroup` names the family and lists its looks.";
2736
2758
  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.";
2737
2759
  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.";
2760
+ 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.";
2761
+ var LAYOUT_LOCK_NOTE = "A Global field with `overridable:false` + `lockReason` (add-field `field`, update-field `patch`) refuses page overrides (LAYOUT_FIELD_NOT_OVERRIDABLE); get_layout scope page lists stored ones as `shadowed`.";
2738
2762
 
2739
2763
  // src/entry-query.ts
2740
2764
  var text = (v) => Array.isArray(v) ? v.map(String).join(",") : String(v);
@@ -2753,7 +2777,7 @@ function whereToQuery(where) {
2753
2777
  }
2754
2778
  function entryListSearch(args) {
2755
2779
  const q = new URLSearchParams();
2756
- for (const key of ["modelId", "pageId", "status"]) if (args[key]) q.set(key, String(args[key]));
2780
+ for (const key of ["modelId", "pageId", "status", "externalKey"]) if (args[key]) q.set(key, String(args[key]));
2757
2781
  for (const [k, v] of whereToQuery(args.where)) q.append(k, v);
2758
2782
  for (const key of ["sort", "page", "limit"]) if (args[key] !== void 0 && args[key] !== null) q.set(key, String(args[key]));
2759
2783
  if (args.detail !== true) q.set("select", "summary");
@@ -2896,7 +2920,9 @@ var fieldType = z.enum([
2896
2920
  "color",
2897
2921
  "json",
2898
2922
  "file",
2899
- "sections"
2923
+ "sections",
2924
+ "multiselect",
2925
+ "icon"
2900
2926
  ]);
2901
2927
  var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
2902
2928
  var fieldKey = z.string().regex(/^[a-zA-Z0-9_-]+$/, "letters, numbers, underscores, and hyphens only");
@@ -2915,8 +2941,8 @@ var uiObject = z.strictObject({
2915
2941
  reorderable: z.boolean().optional().describe(
2916
2942
  "omit (\u2261 true) unless the order is PART OF THE MEANING. `false` LOCKS the list \u2014 a 'three steps' band that must stay three steps in that order, a nav whose order is semantic, a timeline. Valid only where there is a list to lock."
2917
2943
  ),
2918
- control: z.enum(["segmented", "slider"]).optional().describe(
2919
- "editor control for a LEAF. Omit for the default. 'segmented' on a select with a few short options (alignment, theme, size); 'slider' on a number that has config.min < config.max (opacity, columns). Anything else is refused with the rule named."
2944
+ control: z.enum(["segmented", "slider", "rating"]).optional().describe(
2945
+ "editor control for a LEAF. Omit for the default. 'segmented' on a select with a few short options (alignment, theme, size); 'slider' on a number that has config.min < config.max (opacity, columns); 'rating' on a number with config.min, max and step. Anything else is refused with the rule named."
2920
2946
  ),
2921
2947
  // A refine, not an enum: 236 names inlined into every schema that carries `ui` would be paid on
2922
2948
  // every tools/list. The layoutIcon precedent; the server's z.enum is the guard.
@@ -2954,7 +2980,7 @@ var fieldShape = {
2954
2980
  "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'})."
2955
2981
  ),
2956
2982
  config: z.record(z.string(), z.unknown()).optional().describe(
2957
- "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"
2983
+ "per-type config: reference {contentModelId}, multi-reference {contentModelId,min,max}, array {itemType: 'text'|'number'|'date'}, date {includeTime, timezone}, 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"
2958
2984
  ),
2959
2985
  fields: z.array(z.lazy(() => fieldObject)).optional().describe(
2960
2986
  "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."
@@ -2970,8 +2996,8 @@ var fieldShape = {
2970
2996
  rules: z.array(
2971
2997
  z.object({
2972
2998
  field: z.string().describe("the BARE KEY of another field on this same model"),
2973
- op: z.enum(["eq", "neq", "in"]),
2974
- value: z.union([z.string(), z.array(z.string())])
2999
+ op: z.enum(["eq", "neq", "in", "isSet"]),
3000
+ value: z.union([z.string(), z.array(z.string())]).optional().describe("omit for 'isSet' (the source holds any value); required for every other op")
2975
3001
  })
2976
3002
  )
2977
3003
  }).optional().describe(
@@ -2991,7 +3017,9 @@ var fieldShape = {
2991
3017
  searchable: z.boolean().optional().describe("omit (\u2261 true) to index this field for site search; false excludes it"),
2992
3018
  unique: z.boolean().optional().describe(
2993
3019
  "true: no two entries of this model may hold the same value (compared trimmed and case-insensitively). On a repeater child such as a variant SKU it also covers every row of every entry. Only on text, longtext, slug, email, phone, link and number. A clash is refused with 409 `unique_conflict`."
2994
- )
3020
+ ),
3021
+ readOnly: z.union([z.literal(true), z.literal("developer")]).optional().describe(PARAM_TEXT.readOnly),
3022
+ lockReason: z.string().max(200).optional().describe(PARAM_TEXT.lockReason)
2995
3023
  };
2996
3024
  var fieldObject = z.object(fieldShape);
2997
3025
  var urlPatternArg = z.string().nullable().optional().describe(
@@ -3016,7 +3044,7 @@ var fieldsetsArg = z.array(
3016
3044
  );
3017
3045
  var dropProvenance = (sets) => sets?.map(({ uiOrigin: _origin, uiBefore: _before, ...set }) => set) ?? sets;
3018
3046
  var RICH_TEXT_NOTE = "\u{1F534} A field of type 'text' is created as RICH TEXT unless you send `richText: false` on it. Send `richText: false` for every value that is not prose: a name used as a title, a URL, slug, id, SKU, email, phone, CSS class or icon name.";
3019
- var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"];
3047
+ var SCHEMA_ORG_TYPES = ["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness", "JobPosting", "Course", "Recipe", "HowTo", "Service", "Person", "VideoObject"];
3020
3048
  var changefreq = z.enum(["auto", "always", "hourly", "daily", "weekly", "monthly", "yearly", "never"]);
3021
3049
  var jsonObject = z.record(z.string(), z.unknown());
3022
3050
  var seoPatchShape = {
@@ -3030,7 +3058,7 @@ var seoPatchShape = {
3030
3058
  collectionSchemaTypes: z.record(z.string().min(1).max(64), z.enum(SCHEMA_ORG_TYPES)).optional()
3031
3059
  }).optional().describe(SEO_KEY_TEXT.seoDefaults),
3032
3060
  siteSchema: z.union([jsonObject, z.array(jsonObject)]).optional().describe(SEO_KEY_TEXT.siteSchema),
3033
- robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
3061
+ robotsConfig: z.object({ mode: z.enum(["auto", "custom"]), custom: z.string().max(1e4).optional(), disallow: z.array(z.enum(["GPTBot", "OAI-SearchBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "anthropic-ai", "PerplexityBot", "Google-Extended", "CCBot", "Bytespider", "Applebot-Extended", "Amazonbot", "Meta-ExternalAgent"])).max(32).optional() }).optional().describe(SEO_KEY_TEXT.robotsConfig),
3034
3062
  sitemapConfig: z.object({
3035
3063
  enabled: z.boolean(),
3036
3064
  mode: z.enum(["auto", "custom"]).optional(),
@@ -3079,6 +3107,8 @@ function proseType(f) {
3079
3107
  }
3080
3108
  var confirmedHeaders = (confirmed) => confirmed === true ? { [INPUT_CONFIRMED_HEADER]: "true" } : {};
3081
3109
  var withHeaders = (headers) => Object.keys(headers).length > 0 ? { headers } : {};
3110
+ var DRY_RUN_SUMMARY = "Dry run: nothing was written.";
3111
+ var dryRunOf = async (client, method, path, body, headers = {}) => ok(DRY_RUN_SUMMARY, await client.fetchJSON(client.url(path), { method, body: JSON.stringify({ ...body, dryRun: true }), ...withHeaders(headers) }));
3082
3112
  function toField(f) {
3083
3113
  const base = {
3084
3114
  key: f.key,
@@ -3093,7 +3123,10 @@ function toField(f) {
3093
3123
  ...f.defaultValue !== void 0 ? { defaultValue: f.defaultValue } : {},
3094
3124
  ...f.searchable !== void 0 ? { searchable: f.searchable } : {},
3095
3125
  // BE-6: a leaf key like the four above, so it rides on `base` and survives every branch.
3096
- ...f.unique !== void 0 ? { unique: f.unique } : {}
3126
+ ...f.unique !== void 0 ? { unique: f.unique } : {},
3127
+ // The developer lock: stripped here, an agent writing a model back would unlock every field.
3128
+ ...f.readOnly !== void 0 ? { readOnly: f.readOnly } : {},
3129
+ ...f.lockReason !== void 0 ? { lockReason: f.lockReason } : {}
3097
3130
  };
3098
3131
  if (f.type === "group") {
3099
3132
  return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
@@ -3317,6 +3350,7 @@ function buildToolDefs(deps) {
3317
3350
  }
3318
3351
  };
3319
3352
  }
3353
+ const windowArg = z.object({ from: z.string().optional(), until: z.string().optional() });
3320
3354
  const blockObject = z.object({
3321
3355
  type: z.enum([
3322
3356
  "heading",
@@ -3338,11 +3372,12 @@ function buildToolDefs(deps) {
3338
3372
  ]).describe("block type; section/slider/tabs/columns nest child blocks"),
3339
3373
  id: z.string().min(1).describe("stable unique block id"),
3340
3374
  props: z.record(z.string(), z.unknown()).describe(
3341
- "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? primary|secondary|tertiary|brand|dark|light|outline|link, size? sm|md|lg, fullWidth?, arrow? boolean, icon? ui.icon name, newTab? boolean}; 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?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>"
3375
+ "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? primary|secondary|tertiary|brand|dark|light|outline|link, 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?, detailComponentId?, titleField?, excerptField?, limit?, order?, emptyText?, source? {modelId}, filters? [{field, op, value}], sort?, pinnedIds?, excludeCurrent?} \u2014 lists this page's published entries as cards, and renders ONE entry on /<page>/<entrySlug>; with `source`, that model's entries as cards on any page"
3342
3376
  ),
3343
3377
  style: z.record(z.string(), z.unknown()).optional().describe(
3344
- "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). A real marketing band is a `section` block carrying bg + padding + contentWidth."
3345
- )
3378
+ "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."
3379
+ ),
3380
+ window: windowArg.nullable().optional().describe(WINDOW_NOTE)
3346
3381
  });
3347
3382
  const createPageInput = z.object({
3348
3383
  title: z.string().min(1).describe("display title of the page, e.g. 'Home'"),
@@ -3391,7 +3426,8 @@ function buildToolDefs(deps) {
3391
3426
  fields: z.array(fieldObject).optional().describe(
3392
3427
  "the model's typed schema fields. 'group'/'repeater' NEST their child fields (any depth) \u2014 don't flatten zones into top-level fields."
3393
3428
  ),
3394
- fieldsets: fieldsetsArg
3429
+ fieldsets: fieldsetsArg,
3430
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
3395
3431
  });
3396
3432
  const afterArg = z.string().min(1).optional().describe(
3397
3433
  "OPTIONAL insertion point: the key of an existing TOP-LEVEL field to insert this one directly after. Omit to append at the end (the default, and the behaviour before this existed). Use it to put a field where an author expects it instead of at the bottom \u2014 e.g. after:'hero_title' for a subtitle. An unknown key is refused rather than silently appended."
@@ -3399,6 +3435,7 @@ function buildToolDefs(deps) {
3399
3435
  const addFieldInput = z.object({
3400
3436
  modelId: z.string().min(1).optional().describe("the content model to extend (from list_content_models); or send pageId"),
3401
3437
  pageId: z.string().min(1).optional().describe("instead of modelId: the page whose schema to extend (from list_pages / create_page)"),
3438
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun),
3402
3439
  ...fieldShape,
3403
3440
  after: afterArg,
3404
3441
  confirmed: z.boolean().optional().describe(PARAM_TEXT.confirmedInput)
@@ -3413,13 +3450,20 @@ function buildToolDefs(deps) {
3413
3450
  transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars), published wherever the file is used"),
3414
3451
  folderId: z.string().optional().describe("target Media Library folder (defaults to project root)")
3415
3452
  });
3453
+ const upsertShape = {
3454
+ externalKey: z.string().min(1).max(255).optional().describe(PARAM_TEXT.externalKey),
3455
+ source: z.string().max(2048).optional().describe(PARAM_TEXT.upsertSource),
3456
+ runId: z.string().max(100).optional().describe(PARAM_TEXT.upsertRunId)
3457
+ };
3416
3458
  const createEntryInput = z.object({
3417
3459
  contentModelId: z.string().min(1).describe("id of the model this entry belongs to"),
3418
3460
  slug: slug.optional(),
3419
3461
  status: z.enum(["draft", "published"]).optional().describe("defaults to draft"),
3420
3462
  data: z.record(z.string(), z.unknown()).optional().describe("field values keyed by field key"),
3421
3463
  locale: z.string().nullable().optional().describe(PARAM_TEXT.locale),
3422
- translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf)
3464
+ translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf),
3465
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun),
3466
+ ...upsertShape
3423
3467
  });
3424
3468
  const getPageInput = z.object({
3425
3469
  pageId: z.string().min(1).describe(PARAM_TEXT.pageRef)
@@ -3439,6 +3483,7 @@ function buildToolDefs(deps) {
3439
3483
  modelId: z.string().optional().describe("filter by content model id"),
3440
3484
  pageId: z.string().optional().describe("filter by page id (a singleton page has one entry)"),
3441
3485
  status: z.enum(["draft", "published"]).optional(),
3486
+ externalKey: z.string().optional().describe(PARAM_TEXT.externalKeyFilter),
3442
3487
  where: z.record(z.string(), z.unknown()).optional().describe(PARAM_TEXT.where),
3443
3488
  sort: z.string().max(100).optional().describe(PARAM_TEXT.sort),
3444
3489
  page: z.number().int().min(1).optional().describe(PARAM_TEXT.page),
@@ -3456,7 +3501,7 @@ function buildToolDefs(deps) {
3456
3501
  metaDescription: clearableText(500),
3457
3502
  noindex: z.boolean().optional().describe("hide this entry from search engines and site search"),
3458
3503
  ...headPatchShape,
3459
- schemaType: z.enum(["WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"]).nullable().optional(),
3504
+ schemaType: z.enum(SCHEMA_ORG_TYPES).nullable().optional(),
3460
3505
  schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional()
3461
3506
  });
3462
3507
  const updateEntryInput = z.object({
@@ -3504,7 +3549,9 @@ function buildToolDefs(deps) {
3504
3549
  "date",
3505
3550
  "url",
3506
3551
  "consent",
3507
- "hidden"
3552
+ "hidden",
3553
+ "step"
3554
+ // `step` is a divider: the fields after it are one page
3508
3555
  ]),
3509
3556
  placeholder: z.string().optional(),
3510
3557
  helpText: z.string().optional().describe("hint shown under the control, muted"),
@@ -3564,8 +3611,8 @@ function buildToolDefs(deps) {
3564
3611
  // enum omitted could not be echoed back, and an agent reading a component and writing it
3565
3612
  // straight back DESTROYED every prop of that type on a 200.
3566
3613
  type: z.enum(COMPONENT_PROP_TYPES),
3567
- // 'slot' holds ONE nested component instance; config.componentIds restricts what may
3568
- // fill it. Absent here until now, so a slot allowlist was unreachable from stdio even
3614
+ // 'slot' holds ONE nested component instance, or a list with config.multiple (the keys
3615
+ // pass through to the server); config.componentIds restricts what may fill it. Absent here until now, so a slot allowlist was unreachable from stdio even
3569
3616
  // once the enum allowed the type.
3570
3617
  config: z.object({
3571
3618
  componentIds: z.array(z.string()).optional(),
@@ -3587,6 +3634,17 @@ function buildToolDefs(deps) {
3587
3634
  required: z.boolean().optional().describe("mark the field required in the editor. An editor HINT: the API still accepts a placement that leaves it empty, and get_site_composition is what reports one."),
3588
3635
  helpText: z.string().max(200).optional().describe("hint shown under the control, muted (\u2264 200 chars)"),
3589
3636
  placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)"),
3637
+ // Stored markers on a prop: nothing refuses a prop value for them. Declared so an echo keeps them.
3638
+ readOnly: z.union([z.literal(true), z.literal("developer")]).optional(),
3639
+ lockReason: z.string().max(200).optional(),
3640
+ showIf: z.object({
3641
+ match: z.enum(["all", "any"]),
3642
+ rules: z.array(z.object({
3643
+ field: z.string().min(1),
3644
+ op: z.enum(["eq", "neq", "in", "isSet"]),
3645
+ value: z.union([z.string(), z.array(z.string())]).optional()
3646
+ })).min(1).max(20)
3647
+ }).optional().describe("show this prop only while SIBLING props (by key) match: {match:'all'|'any', rules:[{field, op:'eq'|'neq'|'in'|'isSet', value?}]}. 'isSet' takes no value. Editor-only."),
3590
3648
  // ⚠ A STRIP SITE, exactly like OutField above: this is a `z.object`, so a key the model
3591
3649
  // sends and this shape does not declare is dropped BEFORE the request leaves — a 200 with
3592
3650
  // the declaration gone. `ui` rode through the backend's looseObject `config` and died here.
@@ -3613,9 +3671,9 @@ function buildToolDefs(deps) {
3613
3671
  z.object({ type: z.literal("swap-component-variant"), ...sectionTarget, itemId: z.string().min(1), componentId: z.string().min(1) }),
3614
3672
  z.object({ type: z.literal("set-page-state"), ...sectionTarget, state: z.enum(["inherit", "override-content", "customize-structure", "disable", "reset"]) }),
3615
3673
  z.object({ type: z.literal("set-section-state"), ...sectionTarget, state: z.enum(["inherit", "override-content", "customize-structure", "disable", "reset"]) }),
3616
- z.object({ type: z.literal("add-section"), name: z.string().min(1), icon: layoutIcon, zone: z.enum(["before_page", "after_page"]) }),
3674
+ z.object({ type: z.literal("add-section"), name: z.string().min(1), icon: layoutIcon, zone: z.enum(["before_page", "after_page"]), window: windowArg.optional() }),
3617
3675
  z.object({ type: z.literal("restore-section"), section: z.object({ id: z.string().min(1) }).passthrough(), data: z.object({ fields: z.record(z.string(), z.unknown()) }), placement: z.object({ zone: z.enum(["above-page-content", "below-page-content"]), localOrderKey: z.string().min(1), afterSectionId: z.string().optional(), beforeSectionId: z.string().optional() }).optional() }),
3618
- z.object({ type: z.literal("update-section"), ...sectionTarget, name: z.string().min(1).optional(), icon: layoutIcon.optional(), slug: z.string().min(1).optional(), zone: z.enum(["above-page-content", "below-page-content"]).optional(), orderKey: z.string().min(1).optional(), description: z.string().optional() }),
3676
+ z.object({ type: z.literal("update-section"), ...sectionTarget, name: z.string().min(1).optional(), icon: layoutIcon.optional(), slug: z.string().min(1).optional(), zone: z.enum(["above-page-content", "below-page-content"]).optional(), orderKey: z.string().min(1).optional(), description: z.string().optional(), window: windowArg.nullable().optional().describe("as a block's `window`") }),
3619
3677
  z.object({ type: z.literal("remove-section"), ...sectionTarget }),
3620
3678
  z.object({ type: z.literal("move-section"), ...sectionTarget, afterId: z.string().nullable().optional(), beforeId: z.string().nullable().optional() }),
3621
3679
  z.object({ type: z.literal("add-field"), ...sectionTarget, parentFieldId: z.string().optional(), fieldType: z.enum(["text", "longtext", "richtext", "number", "toggle", "date", "image", "file", "link", "email", "phone", "select", "color", "json", "reference", "multi-reference", "group", "repeater"]).optional(), field: z.object({ id: z.string(), slug: z.string(), label: z.string(), type: z.string() }).passthrough().optional() }),
@@ -3664,12 +3722,13 @@ function buildToolDefs(deps) {
3664
3722
  group: z.string().min(1).max(100).optional().describe("the folder editors see in the dashboard's Components tab, by NAME (e.g. 'Sections', 'Forms'); CREATED when missing. Separate from `category`, the picker tab."),
3665
3723
  sectionType: sectionType.optional(),
3666
3724
  variantOf: z.string().min(1).max(255).optional().describe("id/slug of the component this is a VARIANT of: joins or starts its family (422 if its shared inputs are missing)"),
3725
+ from: z.string().min(1).max(255).optional().describe(PARAM_TEXT.fromBuiltin),
3667
3726
  projectId: z.string().nullable().optional().describe("owning project id; null creates a workspace-wide global component"),
3668
3727
  allowedOn: allowedOn.optional(),
3669
3728
  description: z.string().optional().describe(PARAM_TEXT.describes),
3670
3729
  blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
3671
3730
  props: z.array(componentPropObject).default([]).describe(
3672
- "overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all."
3731
+ "overridable fields. DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch: only the leaves a prop targets are click-to-edit. Each one may carry `ui` to say how its control presents in the inspector \u2014 a collapsed group, what titles and thumbnails a table's rows, whether that table's rows can be dragged at all. 'button' is ONE unit {label, href, variant?, size?, arrow?, icon?, newTab?} on a button block's 'props', never a text prop plus a url prop. 'link' an href, 'date' YYYY-MM-DD, 'color' a hex, 'file' {url, name?}, 'icon' an icon name or an image, 'multiselect' a string[] from config.options (required). " + SLOT_MULTIPLE_NOTE
3673
3732
  )
3674
3733
  });
3675
3734
  const updateComponentInput = z.object({
@@ -3729,7 +3788,8 @@ function buildToolDefs(deps) {
3729
3788
  slug: z.string().min(1).optional().describe("a page slug, when one page has it; or pass `pageId`"),
3730
3789
  blockJson: z.array(blockObject).describe("REPLACES this page's whole block composition")
3731
3790
  })
3732
- ).min(1).max(50)
3791
+ ).min(1).max(50),
3792
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
3733
3793
  });
3734
3794
  const listComponentsInput = z.object({
3735
3795
  detail: z.boolean().optional().describe("true = every component's full blockJson and props. Costly and rarely what you want \u2014 read one definition with get_component instead.")
@@ -3795,16 +3855,17 @@ function buildToolDefs(deps) {
3795
3855
  def(
3796
3856
  "update_media",
3797
3857
  "Update a media asset",
3798
- "Describe an existing media asset: set its alt text, caption, transcript or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
3858
+ "Describe an existing media asset: set its alt text, caption, transcript, `decorative` (no alt text needed) or focal point (focalX/focalY, the spot crops keep in frame; delivered images carry it with width, height and thumbHash). A transcript (video/audio only, \u2264 100,000 chars) is stored ON the asset, so every page and entry using the file publishes it \u2014 hosted pages, the .md twin, llms-full.txt and the delivery API. Pass null to clear a field. Use this after your own speech-to-text; BetterCMS does not generate transcripts.",
3799
3859
  z.object({
3800
3860
  assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)"),
3801
3861
  altText: z.string().max(500).nullable().optional().describe("alt text; null clears"),
3802
3862
  caption: z.string().max(1e3).nullable().optional().describe("caption; null clears"),
3803
3863
  transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only."),
3864
+ decorative: z.boolean().optional().describe("needs no alt text"),
3804
3865
  focalX: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalX),
3805
3866
  focalY: z.number().min(0).max(1).nullable().optional().describe(PARAM_TEXT.focalY)
3806
3867
  }).shape,
3807
- async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript, focalX: a.focalX, focalY: a.focalY }))
3868
+ async (c, a) => ok("Updated media asset.", await data(c, "PATCH", `/management/media/${encodeURIComponent(s(a.assetId))}`, { altText: a.altText, caption: a.caption, transcript: a.transcript, decorative: a.decorative, focalX: a.focalX, focalY: a.focalY }))
3808
3869
  ),
3809
3870
  def(
3810
3871
  "list_media",
@@ -3994,15 +4055,27 @@ ${d.outline}` : "Proposed Content structure.", d);
3994
4055
  "update_redirect",
3995
4056
  "Update a redirect",
3996
4057
  "Update an existing redirect in place \u2014 change where it points, its HTTP status, or disable it without deleting. Prefer this over delete+create: it keeps the redirect's id and preserves the chain-collapse rewrites of other rules pointing at it. Only provided fields change. Loops 422, duplicate sources 409.",
3997
- z.object({ redirectId: z.string().min(1).describe("redirect id (from list_redirects)"), sourcePath: z.string().min(1).optional(), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308"]).optional(), isActive: z.boolean().optional().describe("set false to disable without deleting") }).shape,
4058
+ z.object({ redirectId: z.string().min(1).describe("redirect id (from list_redirects)"), sourcePath: z.string().min(1).optional(), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308", "410"]).optional().describe("HTTP status; 410 = Gone"), isActive: z.boolean().optional().describe("set false to disable without deleting") }).shape,
3998
4059
  async (c, a) => ok("Updated redirect.", await data(c, "PATCH", `/management/redirects/${s(a.redirectId)}`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType, isActive: a.isActive }))
3999
4060
  ),
4000
4061
  def(
4001
4062
  "create_redirect",
4002
4063
  "Create a redirect",
4003
4064
  "Create a URL redirect \u2014 e.g. a 301 after renaming a page's slug. `sourcePath` is the path to redirect FROM ('/old-page'), `destination` the path/url TO ('/new-page'). Chains collapse; loops/dupes rejected. Defaults to 301.",
4004
- z.object({ sourcePath: z.string().min(1), destination: z.string().min(1), redirectType: z.enum(["301", "302", "307", "308"]).optional() }).shape,
4005
- async (c, a) => ok("Created redirect.", await data(c, "POST", `/management/redirects`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType }))
4065
+ z.object({ sourcePath: z.string().min(1), destination: z.string().min(1).optional(), redirectType: z.enum(["301", "302", "307", "308", "410"]).optional().describe("default '301'; 410 = Gone, omit destination"), dryRun: z.boolean().optional().describe("true = return the verdict, write nothing") }).shape,
4066
+ async (c, a) => ok(a.dryRun === true ? "Dry run: nothing written." : "Created redirect.", await data(c, "POST", `/management/redirects`, { sourcePath: a.sourcePath, destination: a.destination, redirectType: a.redirectType, dryRun: a.dryRun }))
4067
+ ),
4068
+ def(
4069
+ "import_redirects",
4070
+ "Import redirects",
4071
+ NEW_TOOL_TEXT.import_redirects,
4072
+ z.object({
4073
+ rows: z.array(z.record(z.string(), z.unknown())).optional().describe("[{ sourcePath, destination?, redirectType? }]"),
4074
+ text: z.string().optional().describe("the file's contents, instead of rows"),
4075
+ format: z.enum(["csv", "_redirects", "vercel"]).optional().describe("of `text` (default 'csv')"),
4076
+ dryRun: z.boolean().optional().describe("default false")
4077
+ }).shape,
4078
+ async (c, a) => ok(a.dryRun === true ? "Dry run: nothing written." : "Imported redirects.", await data(c, "POST", `/management/redirects/import`, { rows: a.rows, text: a.text, format: a.format, dryRun: a.dryRun }))
4006
4079
  ),
4007
4080
  def(
4008
4081
  "delete_redirect",
@@ -4225,7 +4298,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4225
4298
  def(
4226
4299
  "submit_conversion_receipt",
4227
4300
  "Record what the conversion codemod could and could not do",
4228
- "Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` (`sourceComplete` is the receipt's own verdict; `liveChecks` gives the last release's `structureUnstamped` route count and `reportSha`) and `pendingTotal`, and echoes the first 40 pending rows with their `fix` \u2014 `{ action, file, line, col?, snippet, why?, kind? }`, `action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; `snippet` and `why` say what to change there. On a `declare-richtext` with `kind: document`, bind the ONE element wrapping every block of the Body, never the paragraph holding its first. Apply every fix, rerun, resubmit: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. A 404 `unknown-brief` means that digest was never issued here. \u{1F534} The same run owes two more things (playbook \xA713 steps 2b and 3b): the `--forms` receipt, submitted as a SECOND call under the same `briefDigest`, and the `styleEdits` of `npx @bettercms-ai/convert --verify-styles` inside this receipt \u2014 one listing a changed class, className or style is refused with 409 `style-edits`. Requires artifact:write.",
4301
+ "Hand BetterCMS the codemod's own account of a run, so the meter can say WHY a path is undeclared. Submit the receipt `npx @bettercms-ai/convert --receipt out.json` wrote as `{ briefDigest, receipt }`, for the SAME `briefDigest` get_conversion_brief { complete: true } returned (its shape: see `receipt`). \u{1F534} A RECEIPT WITH PENDING PATHS IS A PROGRESS REPORT, NOT A FINISH LINE: the response answers `complete` and `pendingTotal`, and echoes the first 40 pending rows with their `fix` (`action` one of `wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`, `bind-data`, `manual`; the row shape: skill bindings; a 404 `unknown-brief` means that digest was never issued here). Apply every fix, rerun, resubmit: only `complete: true` ends a conversion \u2014 never report a site converted on less. It is a RECORD, not a release: nothing on the site changes; the meter reads it on the get_binding_report after the next deploy. \u{1F534} The same run owes two more things (playbook \xA713 steps 2b and 3b): the `--forms` receipt (a SECOND call, same `briefDigest`) and the `styleEdits` of `convert --verify-styles` inside this receipt; any changed class or style is refused with 409 `style-edits`. Requires artifact:write.",
4229
4302
  z.object({
4230
4303
  briefDigest: z.string().min(1).describe("The `briefDigest` get_conversion_brief { complete: true } returned. Must match the receipt's own."),
4231
4304
  receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --receipt out.json` wrote, verbatim: `paths: { declared, rewritten, alreadyDeclared, pending[{ route, scope, path, kind, file, reason, message, fix }] }` plus `styleEdits`. `paths.declared` must equal rewritten + alreadyDeclared + pending.length; each pending `reason` is the converter's own (IN_EXPRESSION, AMBIGUOUS_LITERAL, REPEATER_FIXED_LENGTH, PARSE_ERROR, \u2026); a path with no row reads `not-declared`.")
@@ -4336,7 +4409,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4336
4409
  "update_page",
4337
4410
  "Edit a page",
4338
4411
  "Edit a page: title, slug, SEO metaTitle/metaDescription, structured data (`schemaType`, `schema`), publish status (draft|published), and `blockJson` (its block composition \u2014 passing it REPLACES the whole array, so read get_page first and send its `optimisticVersion` as `ifMatch`). A `blockJson` that swaps a component block to another component comes back with `warnings` (COMPONENT_SWAP_DROPS_OVERRIDES) naming the overrides the new component does not render; they stay on the block, so swapping back restores them. It does NOT change the field SCHEMA \u2014 use add_field (pageId) / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call \u2014 and on a singleton it publishes the page's content entry too; if the entry's workflow stage refuses, the page still goes live and `warnings` carries ENTRY_STILL_DRAFT with the entry id and the reason. STRUCTURED DATA is native SEO, never a field: leave it out and the page gets Automatic JSON-LD from its content (WebSite on the home page, Blog/CollectionPage on a collection's list page, WebPage otherwise); `schemaType` picks an explicit schema.org type whose properties are filled from the content; `schema` is pasted JSON-LD and wins over both (null clears it). " + PAGE_HEAD_NOTE + " " + SECTION_DOCTRINE,
4339
- z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1).describe(PARAM_TEXT.pageRef), title: z.string().optional(), slug: z.string().optional(), blockJson: z.array(blockObject).optional().describe("REPLACES the page's block composition"), metaTitle: z.string().optional(), metaDescription: z.string().optional(), schemaType: z.enum(["auto", "WebPage", "AboutPage", "ContactPage", "CollectionPage", "Blog", "BlogPosting", "Article", "Product", "FAQPage", "Event", "Organization", "LocalBusiness"]).optional().describe("structured data type; 'auto' (the default) derives it from the content"), schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional().describe("custom JSON-LD; wins over schemaType; null clears it"), status: z.enum(["draft", "published"]).optional(), scheduledAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageScheduledAt), unpublishAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageUnpublishAt), locale: z.string().nullable().optional().describe(PARAM_TEXT.locale), translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf), ifMatch: z.number().int().min(0).optional().describe(UPDATE_PAGE_IF_MATCH_NOTE), dynamic: z.object({ previewPath: z.string().nullable().optional().describe(PARAM_TEXT.previewPath), modelId: z.string().min(1).optional().describe(PARAM_TEXT.pageModelId) }).optional().describe(PARAM_TEXT.pageDynamic) }).shape,
4412
+ z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1).describe(PARAM_TEXT.pageRef), title: z.string().optional(), slug: z.string().optional(), blockJson: z.array(blockObject).optional().describe("REPLACES the page's block composition"), metaTitle: z.string().optional(), metaDescription: z.string().optional(), schemaType: z.enum(["auto", ...SCHEMA_ORG_TYPES]).optional().describe("structured data type; 'auto' (the default) derives it from the content"), schema: z.union([z.record(z.string(), z.unknown()), z.array(z.record(z.string(), z.unknown()))]).nullable().optional().describe("custom JSON-LD; wins over schemaType; null clears it"), status: z.enum(["draft", "published"]).optional(), scheduledAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageScheduledAt), unpublishAt: z.string().datetime().nullable().optional().describe(PARAM_TEXT.pageUnpublishAt), locale: z.string().nullable().optional().describe(PARAM_TEXT.locale), translationOf: z.string().nullable().optional().describe(PARAM_TEXT.translationOf), ifMatch: z.number().int().min(0).optional().describe(UPDATE_PAGE_IF_MATCH_NOTE), dynamic: z.object({ previewPath: z.string().nullable().optional().describe(PARAM_TEXT.previewPath), modelId: z.string().min(1).optional().describe(PARAM_TEXT.pageModelId) }).optional().describe(PARAM_TEXT.pageDynamic) }).shape,
4340
4413
  async (c, a) => {
4341
4414
  const res = await c.fetchJSON(
4342
4415
  c.url(`/management/pages/${s(a.pageId)}/meta`),
@@ -4365,7 +4438,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4365
4438
  def(
4366
4439
  "deploy_project",
4367
4440
  "Deploy new source/build",
4368
- "Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`, or the `uploadKey` of an archive you PUT through create_deploy_upload (no size ceiling: use it for anything large, or after a 413/502). SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git and build output/caches (dist, build, .next, .astro, .cache) from the archive: the source is reinstalled and built server-side, and the ~100 MB upload ceiling is enforced in transit (a 413/502 with no server-side detail) \u2014 server-side stripping runs AFTER the upload and cannot rescue an over-limit body. Returns `canvas.lane` (the editor's live-preview lane: `bridge` / `draft-route` / `none`; playbook section 11) and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
4441
+ "Deploy new source/build for the connected project and make it live at its <handle>.bettercms.site. Pass a .tgz or .zip of the project as a base64 string in `data`, or the `uploadKey` of an archive you PUT through create_deploy_upload (no size ceiling: use it for anything large, or after a 413/502). SOURCE (has package.json) is built server-side in an isolated sandbox; a prebuilt static site is served as-is. Returns the release id + sha \u2014 then poll get_deploy_status until it is live. IMPORTANT \u2014 you MUST exclude node_modules, .git and build output/caches (dist, build, .next, .astro, .cache) from the archive: the source is reinstalled and built server-side, and the ~100 MB upload ceiling is enforced in transit (a 413/502 with no server-side detail) \u2014 server-side stripping runs AFTER the upload and cannot rescue an over-limit body. Returns `canvas.lane` (skill bindings) and `editorUrl`, the visual editor to hand the user. On a workspace-wide connection pass `projectId` (from list_projects) to say which site this ships to.",
4369
4442
  z.object({
4370
4443
  data: z.string().min(1).optional().describe("base64 .tgz/.zip of the project; or send uploadKey"),
4371
4444
  uploadKey: z.string().min(1).optional().describe("instead of data: the uploadKey from create_deploy_upload, after PUTting the archive to its uploadUrl"),
@@ -4494,6 +4567,8 @@ ${res.warnings.join("\n")}` : summary, res.data);
4494
4567
  showIf: z.record(z.string(), z.unknown()).nullable().optional().describe("same shape as on add_field; null clears it"),
4495
4568
  ui: z.record(z.string(), z.unknown()).nullable().optional().describe("same vocabulary as on add_field; null clears it"),
4496
4569
  placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).nullable().optional(),
4570
+ readOnly: z.union([z.literal(true), z.literal("developer")]).nullable().optional().describe(PARAM_TEXT.lockClears),
4571
+ lockReason: z.string().max(200).nullable().optional().describe(PARAM_TEXT.lockClears),
4497
4572
  type: fieldType.optional().describe("a new type; needs allowRetype"),
4498
4573
  key: fieldKey.optional().describe("a new key (flat namespace, unique in the schema)"),
4499
4574
  allowRetype: z.boolean().optional().describe("confirm a type change, once the user has agreed"),
@@ -4604,10 +4679,11 @@ ${res.warnings.join("\n")}` : summary, res.data);
4604
4679
  NEW_TOOL_TEXT.create_content_entries,
4605
4680
  z.object({
4606
4681
  contentModelId: z.string().min(1).describe("the collection every entry belongs to (from list_content_models)"),
4607
- entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()) })).min(1).max(200).describe("1-200 entries, each {slug?, data}")
4682
+ entries: z.array(z.object({ slug: z.string().optional(), data: z.record(z.string(), z.unknown()), ...upsertShape })).min(1).max(200).describe("1-200 entries, each {slug?, data}"),
4683
+ dryRun: z.boolean().optional().describe(PARAM_TEXT.dryRun)
4608
4684
  }).shape,
4609
4685
  // The whole body: the per-row receipt is `data`, the counts ride in `meta`.
4610
- async (c, a) => ok("Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries }) }))
4686
+ async (c, a) => ok(a.dryRun ? DRY_RUN_SUMMARY : "Batch receipt.", await c.fetchJSON(c.url(`/management/content/entries/batch`), { method: "POST", body: JSON.stringify({ contentModelId: a.contentModelId, entries: a.entries, dryRun: a.dryRun }) }))
4611
4687
  ),
4612
4688
  def(
4613
4689
  "translate_entry",
@@ -4726,14 +4802,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
4726
4802
  def(
4727
4803
  "get_binding_report",
4728
4804
  "Check what on the live site is editable",
4729
- "The receipt for 'is this site actually EDITABLE?'. Each release scans the built HTML for the element rendering each CMS field value; per slot this returns `mode` ('text-match' = guessed from rendered text, 'declared' = template-declared), what was bound, and `unmatched`: each path with its reason \u2014 not-declared, ambiguous-text, no-element, declared-only, needs-wrap, refused, layout-owned, page-owned, layout-locator-stale, locale-unvarianted, svg-unavailable, partial and unstamped (a page list the content apply left as built: `kind` `list <bound>/<total>`), mismatch (a text-match stamp whose element shows MORE than the value, or no longer the text it was matched on (`data-bcms-from`): never written), report-outdated. DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: `never-generated` \u2014 only a release BetterCMS builds and serves writes a report, so a HEADLESS site never gets one, redeploy or not; `outdated` \u2014 the next release rewrites it. It certifies only that every non-empty field has SOME element carrying its path. Global Sections count only on routes that render them (`layoutRoutes`). `unaddressable` is the copy it cannot see: visible live text no field owns, by route and bucket, biggest first (null = unmeasured: diff visible text against entry values). Over `count / visible > 0.02`, 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 \xA711. `canvas.lane`: the editor's live-preview lane (`bridge`, `draft-route`; `none` \u2192 get_next_steps). `coverage.error` `nothing-bound`: no element of this build is bound, so its percentage describes another site.",
4730
- z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in. The response's `sha` is the build the report describes; `refreshRequired` is true when that is not the build this slot serves. Also per slot: `builtRoutes`: routes the live build has HTML for (null = cannot say). `layoutIncomplete`: Layout paths the last content apply could not write (their publish waits); `layoutUnread`: Layout fields no route reads (`layoutUnreadJudged` false = not judged).") }).shape,
4805
+ "The receipt for 'is this site actually EDITABLE?'. Each release scans the built HTML for the element rendering each CMS field value; per slot this returns `mode` ('text-match' = guessed from rendered text, 'declared' = template-declared), what was bound, and `unmatched`: each path with its reason (reasons: skill bindings). DEPLOY FIRST: before any release there is no report (pages 0, mode null, refreshRequired true). `refreshReason`: a HEADLESS site never gets one (`never-generated`); `layoutRoutes` and `coverage.error`: skill bindings. `unaddressable` is the visible live text no field owns, by route (null = unmeasured: diff visible text against entry values); over `count / visible > 0.02`, fix it in the SOURCE and re-read (playbook \xA711). `canvas.lane`: `none` \u2192 get_next_steps.",
4806
+ z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read \u2014 everything here, `unaddressable` included, is measured PER SLOT, so on a promote-gated project pass 'current' to read the tree the editor frames. Defaults to the slot this project's releases land in. The response's `sha` is the build the report describes; `refreshRequired` is true when that is not the build this slot serves. Other per-slot fields: skill bindings.") }).shape,
4731
4807
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
4732
4808
  ),
4733
4809
  def(
4734
4810
  "get_conversion_brief",
4735
4811
  "Get the brief for making this site's bindings durable",
4736
- "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane`: 'git-connected' = pull_project_source returns a repo; 'archive' = a tarball. Full recipe: section 13 of bettercms://playbook/schema; get_binding_report is the receipt that says you finished. Pass `complete: true` before running the CODEMOD (`npx @bettercms-ai/convert`): the brief UNCAPPED and paged, its full path list pinned under a `briefDigest` \u2014 what the coverage meter measures the build against. The complete brief also carries `forms` (id, status, fields, submitUrl), which `npx @bettercms-ai/convert --forms --brief brief.json --root . --receipt forms-receipt.json` wires into the repository's own <form> elements after the binding pass; read its `forms.pending` and publish every form its `forms.notes` names in the Forms tab (a draft form rejects every submission with a 403).",
4812
+ "The per-project brief for making this site's bindings DURABLE \u2014 read it before you touch the templates. Returns what already exists in the CMS: every live page with its route, every bindable field path with its `label`, `kind`, the CMS value (`current`) and the copy the repo renders today (`original`), the exact attributes to declare, and the ordered steps. Call it for any site whose pages were DERIVED at import, and whenever get_next_steps reports `bindings-not-declared`. It REPLACES re-registering a schema: these pages, fields and values exist already, so create_page / add_field / create_content_model would build a second schema over the first \u2014 edit values with set_page_content instead. `lane`: 'git-connected' = pull_project_source returns a repo; 'archive' = a tarball. Recipe: playbook \xA713; get_binding_report is the finish line. Pass `complete: true` before running the CODEMOD (`npx @bettercms-ai/convert`): the brief UNCAPPED and paged, its full path list pinned under a `briefDigest` \u2014 what the coverage meter measures the build against. The complete brief also carries `forms`: wiring them and publishing the forms it names, skill bindings.",
4737
4813
  z.object({
4738
4814
  complete: z.boolean().optional().describe("true = the COMPLETE brief for a codemod: nothing truncated, no page omitted, paged 50 pages at a time. Page 1 pins the path list the coverage meter measures against."),
4739
4815
  cursor: z.string().optional().describe("The `cursor` from the previous page. Implies complete. A 409 BRIEF_CHANGED means the brief was re-derived mid-pagination \u2014 start again with no cursor.")
@@ -4750,14 +4826,14 @@ ${res.warnings.join("\n")}` : summary, res.data);
4750
4826
  def(
4751
4827
  "get_site_composition",
4752
4828
  "Check every page is assembled from registered components",
4753
- "The receipt for 'is every page assembled from registered components?' \u2014 read-only, computed live. Call it before a whole-site componentize run (playbook \xA712) and after it, as the proof. Per page: `kind` ('blocks' | 'fields-only' | 'empty'), a block census, `nonComponentBlocks` (loose TOP-LEVEL bands the editor's section lane cannot outline), and every `component` placement with the component's `status` ('published' | 'draft' | 'missing' \u2014 a draft renders as an EMPTY STRING on the live site), whether it is `inPicker` (a component with no sectionType is dropped from 'Add a section'), and any `emptyRequiredProps` \u2014 `required` is not enforced on write, so only this read reports it. Site-wide: `components` counts published/draft, `awaitingEvidence` and `awaitingApproval` (a HUMAN approves in the BetterCMS dashboard \u2014 no tool here can, and there is no bulk approve), plus `notInPicker` and `unused` ids; `layout.placements` is the chrome placed in the Global Layout draft. `verdict.everyPageComposed` with `verdict.reasons` is the answer to give the user. Pages are read DRAFT-inclusive: check each page's own `status` before claiming the site is live.",
4829
+ "The receipt for 'is every page assembled from registered components?' \u2014 read-only, computed live. Call it before a whole-site componentize run (playbook \xA712) and after it, as the proof. Per page: `kind`, a block census, `nonComponentBlocks` and every `component` placement with its `status` (a draft renders as an EMPTY STRING on the live site), `inPicker` and `emptyRequiredProps`. Site-wide: counts, `awaitingApproval` (a HUMAN approves in the dashboard; no tool here can), `notInPicker`, `unused` and `layout.placements`. Each field: skill components. `verdict.everyPageComposed` with `verdict.reasons` is the answer to give the user. Pages are read DRAFT-inclusive: check each page's own `status` before claiming the site is live.",
4754
4830
  z.object({}).shape,
4755
4831
  async (c) => ok("Site composition.", await data(c, "GET", `/management/projects/current/composition`))
4756
4832
  ),
4757
4833
  def(
4758
4834
  "get_componentize_plan",
4759
4835
  "Get the plan for turning this site's sections into components",
4760
- "What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). Every page gets its OWN components; a locale copy (`/fr`) reuses its default-locale page's. A COLLECTION TEMPLATE (dynamic page, `pattern: \"{slug}\"`) is planned ONCE, every prop reading the rendered entry: only its prose body and object-row lists are bands; frontmatter (author, images, tags, dates) stays entry fields. `pending` says why a group is not offered (its codes: see `summary`). Chrome (`nav`/`footer`/`modal`: LAYOUT_CHROME, or in the Layout) is NEVER a section: edit it through the Layout. READ `summary: true` FIRST \u2014 the full plan runs to hundreds of KB; `paged` pages it (409 PLAN_CHANGED = start again); the unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
4836
+ "What this site's SECTIONS would become as components \u2014 a proposal that creates nothing and is computed live. For a site whose pages were DERIVED at import, each top-level field GROUP is one section: `hero-*` and `faq-*` keys, and the repeaters the import folded (`group-*`). Per page: each section's `groupKey`, `sectionType` family, leaf `fields` with their CMS values, `shapeHash`, and either the component already rendering it (`reuse.componentId`) or the proposed one (`reuse.proposedSlug`). Under `components`, a NEW one carries the exact `props` and `blockJson` create_component would take; an EXISTING one only `componentId`, `slug` and `name` (nothing is written for it). `pending` says why a group is not offered (its codes: see `summary`). Per-page components, collection templates and chrome (never a section): skill componentize. READ `summary: true` FIRST \u2014 the full plan runs to hundreds of KB; `paged` pages it (409 PLAN_CHANGED = start again); the unpaged full read is what `--plan plan.json` takes. Keep the `digest` \u2014 componentize_sections refuses any other.",
4761
4837
  z.object({
4762
4838
  summary: z.boolean().optional().describe("true = no values: each section's groupKey, family, pending and component slug. Read this first. `pending` codes: `NO_GROUP_ROOT` (keys are still derive-lane names like `h1-welcome` \u2014 rename them into families first), `NOT_A_SECTION` (a lone scalar or the page's own metadata; `detail: ENTRY_METADATA` = collection-template frontmatter, `detail: DECLINED` = declined with componentize_sections `decline`), `IMAGE_AS_TEXT` (a text field holding an image path \u2014 make it an image field), `ALREADY_COMPONENTIZED`, `EMPTY_GROUP`, `NESTED_REPEATER` (three deep; two levels become a nested `table` sub-field), `PAGE_NOT_EMPTY` (blocks this lane does not own and will not overwrite)."),
4763
4839
  paged: z.boolean().optional().describe("true = 10 pages at a time; the response carries `page`, `pageCount` and a `cursor` for the next."),
@@ -4768,7 +4844,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4768
4844
  def(
4769
4845
  "componentize_sections",
4770
4846
  "Turn this site's derived sections into components",
4771
- "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections, how many components it will create and which pages it will rewrite; `dryRun` first. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with ordered `component` instances, one per group, each carrying `props.bind: \"<groupKey>\"` \u2014 the page field group that already holds the copy. Nothing is copied or moved: the page keeps its `fields`, values and bindings, so click-to-edit and the coverage meter do not change (`copy: \"instance\"` moves the words onto each placement instead \u2014 see `copy`). Pass the plan's `digest`; a 409 `stale-plan` means the site changed: read the plan again, show the user what changed and confirm again. Read `components.wouldDuplicate` first: each id is an existing same-family component a NEW one would sit beside (reuse is by identity and slug, never family). Re-running is safe: a placed group returns as ALREADY_COMPONENTIZED. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages, then call it again with `codemod: true` (or run `npx @bettercms-ai/convert --componentize` in the repo) so its templates render these sections from `pages[].blocks`. Every component it CREATES is filed into a Component Group (see `group`). When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized, the plan reports them `NOT_A_SECTION` / `DECLINED` and stops proposing them, and a plan that proposes nothing undeclined is done.",
4847
+ "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections, how many components it will create and which pages it will rewrite; `dryRun` first. It creates each proposed component as a DRAFT and replaces each page's DRAFT blocks with ordered `component` instances, one per group, each carrying `props.bind: \"<groupKey>\"` \u2014 the page field group that already holds the copy. Nothing is copied or moved: the page keeps its `fields`, values and bindings (`copy: \"instance\"` moves the words onto each placement instead \u2014 see `copy`). Pass the plan's `digest`; a 409 `stale-plan` means the site changed: read the plan again, show the user what changed and confirm again. Read `components.wouldDuplicate` first (skill components). Re-running is safe: a placed group returns as ALREADY_COMPONENTIZED. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages, then call it again with `codemod: true` (or run `npx @bettercms-ai/convert --componentize` in the repo) so its templates render these sections from `pages[].blocks`. When the user says a proposed group is NOT a section, pass `decline: true` with those `sections` (and `pageIds`): nothing is componentized and the plan stops proposing them (skill componentize).",
4772
4848
  z.object({
4773
4849
  digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
4774
4850
  pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
@@ -4800,7 +4876,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4800
4876
  def(
4801
4877
  "list_seo_issues",
4802
4878
  "List SEO issues",
4803
- "Scan every published page for SEO problems (missing or over-long titles and descriptions, missing OG image, noindex, absent schema) and return them with severities. Computed live \u2014 nothing is stored.",
4879
+ "Scan every published page for SEO problems (missing or over-long titles and descriptions, missing OG image, noindex, invalid pasted schema) and return them with severities. Also reports accessibility issues as `a11y.*` codes with block ids. Computed live \u2014 nothing is stored.",
4804
4880
  z.object({}).shape,
4805
4881
  async (c) => ok("SEO issues.", await data(c, "GET", `/management/insights/seo-issues`))
4806
4882
  ),
@@ -4997,7 +5073,7 @@ ${res.warnings.join("\n")}` : summary, res.data);
4997
5073
  def(
4998
5074
  "get_deploy_status",
4999
5075
  "Get deploy/build status",
5000
- "Get the connected project's deploy/build status: state (idle|queued|building|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `editorSetup`: whether editing is set up for the live build (never or stale = call editor_setup). Poll this after deploy_project until state is idle with your sha live. `release` is the newest release (id, sha, status, phaseMarks, error, createdAt): while an uploaded build ingests or releases, state is `building`, `release.sha` the new sha and `lastLiveSha` still the old one (re-sending the live sha is a no-op); a failed deploy shows `release.status` failed with its `error`, and `held` state (`held.by`: staff, archived or no-subdomain) means the release waits until that is cleared. `runtime` ({state: ok|crash-loop, restarts, oomKilled}) appears for a server app only: crash-loop means its container keeps restarting (often out of memory) though the build went live fine. Also returns `canvas.lane` (the editor's live-preview lane for this site: `bridge` / `draft-route` / `none`) and `editorUrl`, the visual editor to hand the user.",
5076
+ "Get the connected project's deploy/build status: state (idle|queued|building|failed), whether it's publishing, the live commit sha, when it went live, and any build error. `editorSetup`: whether editing is set up for the live build (never or stale = call editor_setup). Poll this after deploy_project until state is idle with your sha live. `release` and `held`: skill publishing. `runtime` ({state: ok|crash-loop, restarts, oomKilled}) appears for a server app only: crash-loop means its container keeps restarting (often out of memory) though the build went live fine. Also returns `canvas.lane` (skill bindings) and `editorUrl`, the visual editor to hand the user.",
5001
5077
  z.object({}).shape,
5002
5078
  // Flat body, no `data` envelope: `data()` read it as undefined.
5003
5079
  async (c) => ok("Deploy status.", await c.fetchJSON(c.url(`/management/projects/deploy-status`), { method: "GET" }))
@@ -5142,7 +5218,7 @@ ${notes.join("\n")}` : summary, created);
5142
5218
  async (args) => withClient(async (client) => {
5143
5219
  const modelDupes = duplicateKeys(flatFieldKeys(args.fields));
5144
5220
  if (modelDupes.length > 0) return fail(duplicateKeyFailure(modelDupes));
5145
- const model = await client.createModel({
5221
+ const input = {
5146
5222
  name: args.name,
5147
5223
  slug: args.slug,
5148
5224
  ...args.description !== void 0 ? { description: args.description } : {},
@@ -5150,7 +5226,9 @@ ${notes.join("\n")}` : summary, created);
5150
5226
  ...args.kind !== void 0 ? { kind: args.kind } : {},
5151
5227
  ...args.fieldsets !== void 0 ? { fieldsets: dropProvenance(args.fieldsets) } : {},
5152
5228
  fields: toFields(args.fields)
5153
- });
5229
+ };
5230
+ if (args.dryRun) return dryRunOf(client, "POST", `/management/content/models`, input);
5231
+ const model = await client.createModel(input);
5154
5232
  return ok(
5155
5233
  `Created content model '${model.name}' (${model.fields.length} field(s)).`,
5156
5234
  model
@@ -5168,6 +5246,15 @@ ${notes.join("\n")}` : summary, created);
5168
5246
  handler: guard(
5169
5247
  async (args) => withClient(async (client) => {
5170
5248
  if (!args.modelId === !args.pageId) return fail("Pass exactly one of modelId (a content model) or pageId (a page).");
5249
+ if (args.dryRun) {
5250
+ return dryRunOf(
5251
+ client,
5252
+ args.pageId ? "PATCH" : "POST",
5253
+ args.pageId ? `/management/pages/${args.pageId}` : `/management/content/models/${args.modelId}/fields`,
5254
+ { addFields: [toField(args)], ...args.after !== void 0 ? { after: args.after } : {} },
5255
+ confirmedHeaders(args.confirmed)
5256
+ );
5257
+ }
5171
5258
  if (args.pageId) {
5172
5259
  const input2 = {
5173
5260
  // Cast: the SDK types `options` as string[], narrower than the API, which also takes
@@ -5224,19 +5311,23 @@ ${notes.join("\n")}` : summary, created);
5224
5311
  },
5225
5312
  handler: guard(
5226
5313
  async (args) => withClient(async (client) => {
5227
- const created = await client.createEntry({
5314
+ const input = {
5228
5315
  contentModelId: args.contentModelId,
5229
5316
  ...args.slug !== void 0 ? { slug: args.slug } : {},
5230
5317
  ...args.data !== void 0 ? { data: args.data } : {},
5231
5318
  // The SDK's input type predates localization; its body is the input as given.
5232
5319
  ...args.locale !== void 0 ? { locale: args.locale } : {},
5233
- ...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {}
5234
- });
5320
+ ...args.translationOf !== void 0 ? { translationOf: args.translationOf } : {},
5321
+ ...args.externalKey !== void 0 ? { externalKey: args.externalKey, source: args.source, runId: args.runId } : {}
5322
+ };
5323
+ if (args.dryRun) return dryRunOf(client, "POST", `/management/content/entries`, input);
5324
+ const created = await client.createEntry(input);
5235
5325
  const published = args.status !== void 0 && args.status !== "draft" ? await client.updateEntry(created.id, { status: args.status }) : created;
5236
5326
  const { warnings, ...entry } = published;
5237
5327
  const notes = [.../* @__PURE__ */ new Set([...created.warnings ?? [], ...warnings ?? []])];
5238
5328
  return ok(
5239
- [`Created entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...notes].join("\n"),
5329
+ // With an external key the write may have updated the entry, or left it unchanged.
5330
+ [`${args.externalKey ? "Stored" : "Created"} entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...notes].join("\n"),
5240
5331
  entry
5241
5332
  );
5242
5333
  })
@@ -5299,7 +5390,7 @@ ${notes.join("\n")}` : summary, created);
5299
5390
  name: "update_content_entry",
5300
5391
  config: {
5301
5392
  title: "Update a content entry's values",
5302
- description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + ENTRY_META_NOTE,
5393
+ description: "Update a content entry's `data` (field values) and/or status by id. `data` is keyed by field key; a nested 'array' (zone) value is an object { nonRepeatable: {\u2026}, repeatable: [{\u2026}] }, a primitive 'array' is a plain list; an image value may carry this placement's focal {x,y} and crop {top,right,bottom,left} (0\u20131). Use this to edit an existing entry; for a singleton page prefer set_page_content. \u{1F534} `data` REPLACES the entry's whole value object: an optional field you leave out is DELETED, and a required one you leave out fails the whole write. To change one field, pass merge:true, or read the entry (get_content_entry), change that key and send the whole object back. " + FIELD_LOCKED_NOTE + " " + ENTRY_META_NOTE,
5303
5394
  inputSchema: updateEntryInput.shape
5304
5395
  },
5305
5396
  handler: guard(
@@ -5427,7 +5518,7 @@ ${notes.join("\n")}` : summary, created);
5427
5518
  name: "create_form",
5428
5519
  config: {
5429
5520
  title: "Create a form",
5430
- description: "Create a form in the connected project from a field schema. Confirm the fields with the user first. Field types: text, email, textarea, select (needs options), radio (needs options), checkboxes (needs options), checkbox, number, phone, date, url, consent, hidden. Returns the new form's id. It lands as a DRAFT; call publish_form before embedding (a draft form returns 403 on submit). Embed it with `<BcmsForm form={getForm('Name')} />` from @bettercms-ai/next.",
5521
+ description: "Create a form in the connected project from a field schema. Confirm the fields with the user first. Field types: text, email, textarea, select (needs options), radio (needs options), checkboxes (needs options), checkbox, number, phone, date, url, consent, hidden, step (a divider: the fields after it are one page). Returns the new form's id. It lands as a DRAFT; call publish_form before embedding (a draft form returns 403 on submit). Embed it with `<BcmsForm form={getForm('Name')} />` from @bettercms-ai/next.",
5431
5522
  inputSchema: createFormInput.shape
5432
5523
  },
5433
5524
  handler: guard(
@@ -5486,7 +5577,7 @@ ${notes.join("\n")}` : summary, created);
5486
5577
  name: "update_layout",
5487
5578
  config: {
5488
5579
  title: "Apply Layout draft commands",
5489
- description: "Apply permission-shaped values/composition/schema commands to the Global Layout or a page override. Draft-only: this never publishes. Pass the revision from get_layout as ifMatch. ONE command in `command`, or an ordered batch in `commands` (prefer the batch for more than three of anything \u2014 placing site chrome is add-section, add-component and set-bindings in sequence): each command is applied against the revision the previous one RETURNED, so you do not re-read get_layout between them. A batch stops at the first failure and tells you how many were applied and the revision it stopped at \u2014 the earlier ones stand, because a Layout command is not a transaction.",
5580
+ description: "Apply permission-shaped values/composition/schema commands to the Global Layout or a page override. Draft-only: this never publishes. Pass the revision from get_layout as ifMatch. ONE command in `command`, or an ordered batch in `commands` (prefer the batch for more than three of anything \u2014 placing site chrome is add-section, add-component and set-bindings in sequence): each command is applied against the revision the previous one RETURNED, so you do not re-read get_layout between them. A batch stops at the first failure and tells you how many were applied and the revision it stopped at \u2014 the earlier ones stand, because a Layout command is not a transaction. " + LAYOUT_LOCK_NOTE,
5490
5581
  inputSchema: commandLayoutInput.shape
5491
5582
  },
5492
5583
  handler: guard(async (args) => withClient(async (client) => {
@@ -5524,7 +5615,7 @@ ${notes.join("\n")}` : summary, created);
5524
5615
  name: "publish_layout",
5525
5616
  config: {
5526
5617
  title: "Publish the Global Layout draft",
5527
- description: "Publish the connected project's GLOBAL Layout draft (navigation, footer, every reserved section). On a site BetterCMS serves, a Layout publish never builds: it writes the VALUES into the live pages, and a STRUCTURE change (Sections added, removed, moved) waits for a Rebuild or a push (`layout-rebuild-required`); a custom host gets its deploy hook. Until published, get_layout copy:'published' answers PUBLISHED_LAYOUT_UNAVAILABLE. Pass get_layout's `revision` as ifMatch; a stale one returns 409 \u2014 re-read, never retry blindly. A 422 lists validation issues (global, or per page override: pageSlug, copy) to fix with update_layout. A new project's chrome is draft: publish_component navigation-default and footer-default first (each independently). navigation.logo is optional: empty, the nav shows the brand mark (set_brand_assets) or the site name. Never add a logo the site does not have. Verify with get_layout copy:'published' \u2014 this response is the write's echo, not a receipt. Page Layout overrides go live with the page (update_page status:'published'). 403 PUBLISH_NOT_GRANTED: this connection may author drafts, not publish \u2014 say so; the user allows it or publishes from the dashboard.",
5618
+ description: "Publish the connected project's GLOBAL Layout draft (navigation, footer, every reserved section). On a site BetterCMS serves, a Layout publish never builds: it writes the VALUES into the live pages, and a STRUCTURE change (Sections added, removed, moved) waits for a Rebuild or a push (`layout-rebuild-required`); a custom host gets its deploy hook. Until published, get_layout copy:'published' answers PUBLISHED_LAYOUT_UNAVAILABLE. Pass get_layout's `revision` as ifMatch; a stale one returns 409 \u2014 re-read, never retry blindly. A 422 lists validation issues (global, or per page override: pageSlug, copy) to fix with update_layout. A new project's chrome is draft: publish_component navigation-default and footer-default first (the order and the optional logo: skill components). Verify with get_layout copy:'published' \u2014 this response is the write's echo, not a receipt. Page Layout overrides go live with the page (update_page status:'published'). 403 PUBLISH_NOT_GRANTED: this connection may author drafts, not publish \u2014 say so; the user allows it or publishes from the dashboard.",
5528
5619
  inputSchema: publishLayoutInput.shape
5529
5620
  },
5530
5621
  handler: guard(async (args) => withClient(async (client) => {
@@ -5678,14 +5769,17 @@ ${notes.join("\n")}` : summary, created);
5678
5769
  try {
5679
5770
  const written = await client.fetchJSON(
5680
5771
  client.url(`/management/pages/${pageId}/meta`),
5681
- { method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson }) }
5772
+ { method: "PATCH", body: JSON.stringify({ blockJson: page.blockJson, ...args.dryRun ? { dryRun: true } : {} }) }
5682
5773
  );
5683
- results.push({ ok: true, pageId, slug: written.data?.slug ?? page.slug, path: written.data?.path, blocks: page.blockJson.length });
5774
+ results.push({ ok: true, ...args.dryRun ? { status: "would-update" } : {}, pageId, slug: written.data?.slug ?? page.slug, path: written.data?.path, blocks: page.blockJson.length });
5684
5775
  } catch (err) {
5685
5776
  results.push({ ok: false, pageId, slug: page.slug, error: errorText(err) });
5686
5777
  }
5687
5778
  }
5688
5779
  const failed = results.filter((r) => !r.ok).length;
5780
+ if (args.dryRun) {
5781
+ return ok(DRY_RUN_SUMMARY, { dryRun: true, results, wouldCompose: results.length - failed, failed });
5782
+ }
5689
5783
  return ok(
5690
5784
  `Composed ${results.length - failed} of ${args.pages.length} page(s)${failed ? `; ${failed} failed \u2014 see the per-item errors` : ""}. These are page DRAFTS: publish each with update_page status:'published'. Then call get_site_composition for the receipt.`,
5691
5785
  results
@@ -5859,7 +5953,7 @@ ${lines.join("\n")}`, found);
5859
5953
  name: "publish_component",
5860
5954
  config: {
5861
5955
  title: "Publish a component",
5862
- description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. Drafts (create_component, update_component) render as NOTHING live, with no error: publish every component you place on a page. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. Its `readiness` names what is missing \u2014 relay it to the user instead of retrying or routing around it. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
5956
+ description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. Drafts (create_component, update_component) render as NOTHING live, with no error: publish every component you place on a page. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it. It asks for exact validation evidence and a human's approval, never of a component you just made with create_component (which ones: skill components). Its `readiness` names what is missing \u2014 relay it to the user instead of retrying or routing around it. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
5863
5957
  inputSchema: getComponentInput.shape
5864
5958
  },
5865
5959
  handler: guard(
@@ -6801,13 +6895,13 @@ delivered value \u2014 they change editor chrome only.
6801
6895
  { "key": "testimonials", "label": "Testimonials", "type": "repeater",
6802
6896
  "ui": { "collapsed": true, "preview": { "title": "author", "media": "avatar" },
6803
6897
  "layout": "grid", "icon": "double-quote", "itemLabel": "Testimonial" },
6804
- "helpText": "Three to five. Shortest quotes read best.",
6898
+ "helpText": "Three to five.",
6805
6899
  "fields": [ { "key": "author", ... }, { "key": "avatar", "type": "image", ... }, ... ] }
6806
6900
  \`\`\`
6807
6901
 
6808
- - **\`helpText\`** \u2014 one line UNDER the field telling the author what to write here, not what the
6809
- field is. "Roughly 60 characters; it becomes the browser tab title" earns its place.
6810
- "The title field" does not.
6902
+ - **\`helpText\`** \u2014 the exception: most fields need none. Write it only when the author needs it to
6903
+ type the right value: a format or unit ("Price in cents."), a placement the canvas cannot show, a
6904
+ real constraint, a fallback. Never restate the label ("The title field"), never coach ("Keep it short").
6811
6905
  - **\`showIf\`** \u2014 hide a field until another has a value:
6812
6906
  \`{match:'all',rules:[{field:'link_type',op:'eq',value:'external'}]}\`. Rules name fields by
6813
6907
  BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
@@ -7130,7 +7224,7 @@ Author a form (then the user embeds it with \`<BcmsForm form={getForm('Name')} /
7130
7224
  1. **Discover** \u2014 \`list_forms\` to see existing forms; \`get_form\` to read one before editing.
7131
7225
  2. **Collect fields (loop)** \u2014 for each: key (machine key for the value), label, type. Types:
7132
7226
  text, email, textarea, select (needs \`options: string[]\`), checkbox, checkboxes (needs
7133
- \`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden. Optional per
7227
+ \`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden, step (divider). Optional per
7134
7228
  field: required?, placeholder?, helpText? (shown under the input), defaultValue?,
7135
7229
  \`validation\` (\`emailPolicy\` on email, \`min\`/\`max\` on number, \`phoneFormat\` on phone,
7136
7230
  \`pattern\` on text/textarea/url \u2014 a rule on any other type is a 400),
@@ -7156,8 +7250,9 @@ error-prone, so go slow and confirm. Never guess the layout.
7156
7250
  the same prop keys across a family or a swap drops content.
7157
7251
  4. **Props** \u2014 declare only what should really be editable:
7158
7252
  { key, label, target: { blockId, path },
7159
- type: text|richtext|image|url|boolean|number|select|group|table|slot|form }. 'select' needs
7160
- config.options; 'group'/'table' need config.fields; 'slot' holds ONE nested component;
7253
+ type: text|richtext|image|url|boolean|number|select|group|table|slot|form|link|button|date|color|file|icon|multiselect }.
7254
+ 'select'/'multiselect' need config.options; 'group'/'table' need config.fields; a 'button'
7255
+ is ONE prop on a button block's 'props', never a label prop plus a url prop; 'slot' holds ONE nested component (a list with config.multiple);
7161
7256
  'form' holds ONE Form document \u2014 point target.path at 'props' on an empty form block and set
7162
7257
  defaultValue to {formId}. Never re-declare a form's FIELDS as props: the Form document owns
7163
7258
  them, and a second copy is a second source of truth that drifts on the first edit.