@bettercms-ai/mcp 0.38.1 → 0.41.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
@@ -2108,6 +2108,21 @@ var fieldType = z.enum([
2108
2108
  ]);
2109
2109
  var slug = z.string().regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only");
2110
2110
  var fieldKey = z.string().regex(/^[a-zA-Z0-9_]+$/, "letters, numbers, and underscores only");
2111
+ var uiObject = z.object({
2112
+ collapsed: z.boolean().optional().describe("start this panel collapsed. Use it for long optional sections."),
2113
+ preview: z.object({
2114
+ title: z.string().min(1).optional().describe("CHILD KEY whose value titles a collapsed row"),
2115
+ media: z.string().min(1).optional().describe("CHILD KEY whose value is the row's thumbnail (image/file/url all work)")
2116
+ }).optional().describe(
2117
+ "how ONE ITEM summarises itself when collapsed. Both slots name a CHILD KEY of THIS field/prop \u2014 not a value, not a dotted path. Without it a repeater of testimonials reads 'Item 1, Item 2, Item 3'; with {title:'author', media:'avatar'} it reads the names with their faces."
2118
+ ),
2119
+ layout: z.enum(["list", "grid"]).optional().describe(
2120
+ "how the ITEMS are arranged. Omit (\u2261 'list') for rows of text; 'grid' for items whose thumbnail is the thing you scan \u2014 a gallery, a logo wall, a team. Same placement rule as `preview`."
2121
+ ),
2122
+ reorderable: z.boolean().optional().describe(
2123
+ "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."
2124
+ )
2125
+ });
2111
2126
  var fieldShape = {
2112
2127
  key: fieldKey.describe(
2113
2128
  "machine field key. \u{1F534} UNIQUE ACROSS THE WHOLE MODEL \u2014 API IDs share ONE FLAT NAMESPACE, so a field nested inside a group must NOT reuse a key used by another field or group. Every section's heading cannot be 'title'. Prefix with the section: 'hero_title', 'pricing_title', 'faq_title'. Reusing a key is refused, and if it slips through it leaves permanent errors in the editor and breaks conditional-visibility rules, which reference fields by bare key."
@@ -2118,15 +2133,65 @@ var fieldShape = {
2118
2133
  richText: z.boolean().optional().describe(
2119
2134
  "prose formatting. DEFAULTS TO TRUE for 'text': the field is created as rich text so editors can bold, link and format it on the canvas, and the API returns rich text (render with rich() from @bettercms-ai/sdk; plain() for titles and meta). Pass false for a value that is NOT prose and must stay a bare string \u2014 a URL/href, a slug, an id, an email, a phone number, a CSS class, an icon name. A link stored as rich text will not work as an href."
2120
2135
  ),
2121
- options: z.array(z.string()).optional().describe("choices when type is 'select'"),
2136
+ options: z.array(z.union([z.string(), z.object({ label: z.string(), value: z.string() })])).optional().describe(
2137
+ "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'})."
2138
+ ),
2122
2139
  config: z.record(z.string(), z.unknown()).optional().describe(
2123
2140
  "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"
2124
2141
  ),
2125
2142
  fields: z.array(z.lazy(() => fieldObject)).optional().describe(
2126
2143
  "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."
2144
+ ),
2145
+ // ── Authoring chrome + editor behaviour ────────────────────────────────────
2146
+ // These three already persist end-to-end and already have a panel in the dashboard's schema
2147
+ // builder. They were unreachable from MCP for ONE reason: the OutField/toField whitelist
2148
+ // below did not model them, so they were stripped after the model had gone to the trouble of
2149
+ // sending them. Declaring them here is what makes the existing panel agent-writable.
2150
+ helpText: z.string().max(500).optional().describe(
2151
+ "one line of guidance shown UNDER this field in the editor \u2014 what to write here, not what the field is. Write it for the person typing ('Roughly 60 characters; it becomes the browser tab title'), not for a developer."
2152
+ ),
2153
+ showIf: z.object({
2154
+ match: z.enum(["all", "any"]),
2155
+ rules: z.array(
2156
+ z.object({
2157
+ field: z.string().describe("the BARE KEY of another field on this same model"),
2158
+ op: z.enum(["eq", "neq", "in"]),
2159
+ value: z.union([z.string(), z.array(z.string())])
2160
+ })
2161
+ )
2162
+ }).optional().describe(
2163
+ "conditional visibility \u2014 hide this field until another field has a given value (e.g. show `external_url` only when `link_type` is 'external'). Admin-only: it changes what an author SEES, never what is stored or delivered. Rules reference fields by BARE KEY, and the key namespace is flat, so the key must exist on this model."
2164
+ ),
2165
+ ui: uiObject.optional().describe(
2166
+ "AUTHORING CHROME \u2014 how this field presents in the editor. Never a delivery effect. `preview` and `layout` are valid ONLY on a nesting field ('group', 'repeater', an 'array' with config.zones, or 'modular'); `reorderable` only where there is a LIST to lock ('repeater', an 'array' with config.zones.repeatable, or 'modular' \u2014 a 'group' holds one item). preview keys must name real children, or the write is refused with the valid keys listed. An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_content_model / get_page to confirm it landed. There is deliberately no `order`: field order is the array index \u2014 use add_field's `after` to insert."
2167
+ ),
2168
+ // ── G5: the four keys the backend field schema accepts that this whitelist stripped. ──
2169
+ fieldsetId: z.string().min(1).optional().describe(
2170
+ "id of one of this model's `fieldsets` (create_content_model / update_content_model). The field is shown inside that card in the editor. Editor-only: never changes what is stored or delivered."
2171
+ ),
2172
+ placement: z.enum(["cover", "title", "excerpt", "body", "inline", "panel"]).optional().describe(
2173
+ "where the control sits in the entry editor: 'title' the entry's heading, 'cover' its lead image, 'excerpt' the summary, 'body' the document body, 'inline' or 'panel'. Omit to let the editor decide. Editor-only."
2174
+ ),
2175
+ defaultValue: z.unknown().optional().describe("the value a new entry starts with"),
2176
+ searchable: z.boolean().optional().describe("omit (\u2261 true) to index this field for site search; false excludes it"),
2177
+ unique: z.boolean().optional().describe(
2178
+ "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`."
2127
2179
  )
2128
2180
  };
2129
2181
  var fieldObject = z.object(fieldShape);
2182
+ var urlPatternArg = z.string().nullable().optional().describe(
2183
+ "the public URL of one entry, e.g. '/blog/:slug'. Must start with '/' and contain ':slug' exactly once ('{slug}' is refused). Drives RSS item links and the automatic 301 when an entry's slug changes. null clears it."
2184
+ );
2185
+ var fieldsetsArg = z.array(
2186
+ z.object({
2187
+ id: z.string().min(1),
2188
+ name: z.string().min(1).max(60),
2189
+ description: z.string().max(500).optional()
2190
+ })
2191
+ ).max(30).optional().describe(
2192
+ "the model's editor cards: [{id, name, description?}], at most 30, ids unique, order = array index. `description` is one line under the card title. A field joins one with `fieldsetId`. Editor-only: never changes what is stored or delivered."
2193
+ );
2194
+ 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.";
2130
2195
  function flatFieldKeys(fields) {
2131
2196
  const out = [];
2132
2197
  const childrenOf = (f) => {
@@ -2164,7 +2229,17 @@ function toField(f) {
2164
2229
  const base = {
2165
2230
  key: f.key,
2166
2231
  label: f.label,
2167
- ...f.required !== void 0 ? { required: f.required } : {}
2232
+ ...f.required !== void 0 ? { required: f.required } : {},
2233
+ ...f.helpText !== void 0 ? { helpText: f.helpText } : {},
2234
+ ...f.showIf !== void 0 ? { showIf: f.showIf } : {},
2235
+ ...f.ui !== void 0 ? { ui: f.ui } : {},
2236
+ // G5: these four persist on the backend and were stripped here, like helpText once was.
2237
+ ...f.fieldsetId !== void 0 ? { fieldsetId: f.fieldsetId } : {},
2238
+ ...f.placement !== void 0 ? { placement: f.placement } : {},
2239
+ ...f.defaultValue !== void 0 ? { defaultValue: f.defaultValue } : {},
2240
+ ...f.searchable !== void 0 ? { searchable: f.searchable } : {},
2241
+ // BE-6: a leaf key like the four above, so it rides on `base` and survives every branch.
2242
+ ...f.unique !== void 0 ? { unique: f.unique } : {}
2168
2243
  };
2169
2244
  if (f.type === "group") {
2170
2245
  return { ...base, type: "array", config: { zones: { nonRepeatable: toFields(f.fields) } } };
@@ -2358,24 +2433,43 @@ function buildToolDefs(deps) {
2358
2433
  text: z.string().min(1).describe("the content to derive SEO metadata from"),
2359
2434
  context: z.string().optional().describe("optional surrounding context, e.g. the page slug")
2360
2435
  });
2436
+ const connectNativeAiInput = z.object({
2437
+ capabilities: z.array(z.string().min(1)).min(1).optional().describe(
2438
+ "optional \u2014 any of inspect, propose, implement, cancel, question. Defaults to inspect+propose+cancel."
2439
+ )
2440
+ });
2441
+ const nativeBridgeHeartbeatInput = z.object({
2442
+ protocolVersion: z.string().min(1).describe("executor protocol version; currently '1'"),
2443
+ capabilities: z.array(z.string().min(1)).min(1).describe(
2444
+ "what this executor can do: any of inspect, propose, implement, cancel, question. Must include inspect, propose and cancel. An unknown value is refused, never ignored."
2445
+ ),
2446
+ session: z.object({ sessionId: z.string(), sessionEpoch: z.number().int().positive() }).optional().describe("the `session` from the previous response; omit on first contact to reconnect")
2447
+ });
2361
2448
  const createModelInput = z.object({
2362
2449
  name: z.string().min(1).describe("human model name, e.g. 'Blog Post'"),
2363
2450
  slug: slug.describe("url-safe unique slug, e.g. 'blog-post'"),
2364
2451
  description: z.string().optional(),
2452
+ urlPattern: urlPatternArg,
2365
2453
  kind: z.enum(["model", "block"]).optional().describe(
2366
2454
  "'model' (default) = a collection with its own entries. 'block' = a type that exists only to be stacked inside another model's 'modular' field \u2014 it holds no entries, and create_content_entry against it is refused. Create blocks FIRST, then the model whose modular field lists their slugs. Cannot be changed later."
2367
2455
  ),
2368
2456
  fields: z.array(fieldObject).optional().describe(
2369
2457
  "the model's typed schema fields. 'group'/'repeater' NEST their child fields (any depth) \u2014 don't flatten zones into top-level fields."
2370
- )
2458
+ ),
2459
+ fieldsets: fieldsetsArg
2371
2460
  });
2461
+ const afterArg = z.string().min(1).optional().describe(
2462
+ "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."
2463
+ );
2372
2464
  const addFieldInput = z.object({
2373
2465
  modelId: z.string().min(1).describe("id of the content model to extend"),
2374
- ...fieldShape
2466
+ ...fieldShape,
2467
+ after: afterArg
2375
2468
  });
2376
2469
  const addPageFieldInput = z.object({
2377
2470
  pageId: z.string().min(1).describe("id of the page to extend (from list_pages / create_page)"),
2378
- ...fieldShape
2471
+ ...fieldShape,
2472
+ after: afterArg
2379
2473
  });
2380
2474
  const uploadAssetInput = z.object({
2381
2475
  localPath: z.string().min(1).optional().describe("absolute path to a local file (e.g. a repo image); provide this OR url"),
@@ -2383,6 +2477,7 @@ function buildToolDefs(deps) {
2383
2477
  filename: z.string().optional().describe("override the stored filename"),
2384
2478
  altText: z.string().optional().describe("accessibility alt text"),
2385
2479
  caption: z.string().optional(),
2480
+ transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars), published wherever the file is used"),
2386
2481
  folderId: z.string().optional().describe("target Media Library folder (defaults to project root)")
2387
2482
  });
2388
2483
  const createEntryInput = z.object({
@@ -2510,7 +2605,13 @@ function buildToolDefs(deps) {
2510
2605
  // get_site_composition counts, never a refusal. Parity: componentPropDefSchema on the server.
2511
2606
  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."),
2512
2607
  helpText: z.string().max(200).optional().describe("hint shown under the control, muted (\u2264 200 chars)"),
2513
- placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)")
2608
+ placeholder: z.string().max(120).optional().describe("ghost text inside a text-ish control (\u2264 120 chars)"),
2609
+ // ⚠ A STRIP SITE, exactly like OutField above: this is a `z.object`, so a key the model
2610
+ // sends and this shape does not declare is dropped BEFORE the request leaves — a 200 with
2611
+ // the declaration gone. `ui` rode through the backend's looseObject `config` and died here.
2612
+ ui: uiObject.optional().describe(
2613
+ "AUTHORING CHROME for this prop's control in the inspector \u2014 never a delivery effect, and the same object a FIELD's `ui` takes. `preview` and `layout` are valid on 'group', 'table' and 'slot' only; `reorderable` on 'table' ALONE, the one prop type whose value is a LIST (a 'group' is one object, a 'slot' one component instance). preview keys name a SUB-FIELD of this prop's config.fields and are checked against them (on a 'slot' the shape is checked but the keys cannot be \u2014 they belong to whichever component fills it). An unknown key inside `ui` is a 400, not a silent strip. Read it back with get_component: a prop whose `ui` is absent there was not stored."
2614
+ )
2514
2615
  });
2515
2616
  const getLayoutInput = z.object({
2516
2617
  scope: z.enum(["global", "page"]).default("global"),
@@ -2585,7 +2686,9 @@ function buildToolDefs(deps) {
2585
2686
  allowedOn: allowedOn.optional(),
2586
2687
  description: z.string().optional(),
2587
2688
  blockJson: z.array(blockObject).default([]).describe("the component's block tree"),
2588
- props: z.array(componentPropObject).default([]).describe("overridable fields")
2689
+ props: z.array(componentPropObject).default([]).describe(
2690
+ "overridable fields. 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."
2691
+ )
2589
2692
  });
2590
2693
  const updateComponentInput = z.object({
2591
2694
  componentId: z.string().min(1).describe("component id (from list_components)"),
@@ -2598,7 +2701,21 @@ function buildToolDefs(deps) {
2598
2701
  allowedOn: allowedOn.optional().describe("REPLACES the placement allowlist"),
2599
2702
  description: z.string().optional(),
2600
2703
  blockJson: z.array(blockObject).optional().describe("REPLACES the block tree"),
2601
- props: z.array(componentPropObject).optional()
2704
+ props: z.array(componentPropObject).optional().describe(
2705
+ "REPLACES the prop array \u2014 include every prop you want to keep, WITH its stored `ui`, or that prop's authoring chrome is dropped along with the prop. Read get_component first."
2706
+ )
2707
+ });
2708
+ const setComponentSourceInput = z.object({
2709
+ componentId: z.string().min(1).describe("component id (from list_components)"),
2710
+ path: z.string().min(1).max(512).describe("file path relative to the app root, e.g. src/components/sections/Hero.astro"),
2711
+ export: z.string().min(1).max(128).optional().describe("named export that renders it; omit for a default export"),
2712
+ kind: z.enum(["file", "section"]).optional().describe("'file' (props are this component's fields; default) or 'section' (a codemod-extracted section: props { blockId, bind, overrides, page })")
2713
+ });
2714
+ const submitComponentizeReceiptInput = z.object({
2715
+ receipt: z.record(z.string(), z.unknown()).describe("The receipt `npx @bettercms-ai/convert --componentize --receipt <file>` wrote, verbatim.")
2716
+ });
2717
+ const clearComponentSourceInput = z.object({
2718
+ componentId: z.string().min(1).describe("component id (from list_components)")
2602
2719
  });
2603
2720
  const getComponentInput = z.object({
2604
2721
  componentId: z.string().min(1).describe("component id (from list_components)")
@@ -2751,9 +2868,22 @@ function buildToolDefs(deps) {
2751
2868
  filename: z.string().min(1).describe("file name incl. extension"),
2752
2869
  altText: z.string().optional(),
2753
2870
  caption: z.string().optional(),
2871
+ transcript: z.string().max(1e5).optional().describe("video/audio only: the spoken words (\u2264 100,000 chars)"),
2754
2872
  folderId: z.string().optional()
2755
2873
  }).shape,
2756
- async (c, a) => ok("Media asset.", await data(c, "POST", `/management/media/from-upload`, { assetId: a.assetId, uploadKey: a.uploadKey, filename: a.filename, altText: a.altText, caption: a.caption, folderId: a.folderId }))
2874
+ async (c, a) => ok("Media asset.", await data(c, "POST", `/management/media/from-upload`, { assetId: a.assetId, uploadKey: a.uploadKey, filename: a.filename, altText: a.altText, caption: a.caption, transcript: a.transcript, folderId: a.folderId }))
2875
+ ),
2876
+ def(
2877
+ "update_media",
2878
+ "Update a media asset",
2879
+ "Describe an existing media asset: set its alt text, caption or transcript. 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.",
2880
+ z.object({
2881
+ assetId: z.string().min(1).describe("media asset id (from list_media / upload_asset)"),
2882
+ altText: z.string().max(500).nullable().optional().describe("alt text; null clears"),
2883
+ caption: z.string().max(1e3).nullable().optional().describe("caption; null clears"),
2884
+ transcript: z.string().max(1e5).nullable().optional().describe("the spoken words; null clears. Video/audio only.")
2885
+ }).shape,
2886
+ 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 }))
2757
2887
  ),
2758
2888
  def(
2759
2889
  "list_media",
@@ -3016,16 +3146,17 @@ function buildToolDefs(deps) {
3016
3146
  def(
3017
3147
  "get_content_model",
3018
3148
  "Get a content model",
3019
- "Get one content model by id INCLUDING its full field schema (keys, types, nested group/repeater children). Read this before add_field so you know the existing keys.",
3149
+ "Get one content model by id INCLUDING its full field schema (keys, types, nested group/repeater children) AND each field's stored authoring props \u2014 `helpText`, `showIf`, `ui`. Read it before add_field so you know the existing keys, and AFTER declaring `ui`/`showIf`/`helpText` as the receipt that the declaration actually landed: a prop that is missing from this response was not stored, whatever the write returned.",
3020
3150
  z.object({ modelId: z.string().min(1) }).shape,
3021
3151
  async (c, a) => ok("Content model.", await data(c, "GET", `/management/content/models/${s(a.modelId)}`))
3022
3152
  ),
3023
3153
  def(
3024
3154
  "update_content_model",
3025
3155
  "Update a content model's metadata",
3026
- "Rename a content model or edit its description/slug (metadata only \u2014 does NOT touch fields; use add_field to extend the schema). Provide modelId plus the fields to change.",
3027
- z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional() }).shape,
3028
- async (c, a) => ok("Updated content model.", await data(c, "PATCH", `/management/content/models/${s(a.modelId)}`, { name: a.name, slug: a.slug, description: a.description }))
3156
+ "Rename a content model, edit its description/slug/urlPattern, or set its `fieldsets`. Never touches `fields`: use add_field to extend the schema. Provide modelId plus what to change. `fieldsets` REPLACES the list (send null to drop them all); then put a field in one with add_field's `fieldsetId`. `urlPattern` is the collection's entry URL, e.g. '/blog/:slug' (null clears it). A slug already used in this project and branch is refused with 409 `slug_taken`.",
3157
+ z.object({ modelId: z.string().min(1), name: z.string().optional(), slug: z.string().optional(), description: z.string().optional(), urlPattern: urlPatternArg, fieldsets: fieldsetsArg.nullable() }).shape,
3158
+ // `fields` is deliberately not sent: the PATCH replaces `fields` wholesale when present.
3159
+ async (c, a) => ok("Updated content model.", await data(c, "PATCH", `/management/content/models/${s(a.modelId)}`, { name: a.name, slug: a.slug, description: a.description, urlPattern: a.urlPattern, fieldsets: a.fieldsets }))
3029
3160
  ),
3030
3161
  def(
3031
3162
  "get_content_types",
@@ -3098,7 +3229,7 @@ function buildToolDefs(deps) {
3098
3229
  def(
3099
3230
  "get_binding_report",
3100
3231
  "Check what on the live site is editable",
3101
- "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), `pagesInspected`, `bound` (elements carrying a binding), and `unmatched` \u2014 per page, each path with its kind and the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report and this answers pages 0, mode null, refreshRequired true. It certifies exactly one thing \u2014 that every non-empty field of every page has SOME element carrying its path. It cannot see copy that was never modelled, so diff each route's visible text against its entry values yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for (null when unknown: runtime release, >500 routes, or a report older than this field). `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge` (the build ships BcmsDraftBridge), `draft-route` (a node runtime rendering drafts server-side) or `none`, in which case get_next_steps carries the recipe (playbook section 11); null means the report predates the field. `unaddressable` counts visible text on the live site that no field owns, as the editor's x-ray measured it against the sha now serving \u2014 the one thing `unmatched` structurally cannot see; absent means nobody has turned that control on for this build. `unaddressable` is measured PER SLOT by the editor, which frames `current`; on a promote-gated project pass slot:'current' to read it. Pass `slot` ('current' or 'staging') to read the other tree; the default is the slot this project's releases land in.",
3232
+ "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), `pagesInspected`, `bound` (elements carrying a binding), and `unmatched` \u2014 per page, each path with its kind and the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report and this answers pages 0, mode null, refreshRequired true. It certifies exactly one thing \u2014 that every non-empty field of every page has SOME element carrying its path. It cannot see copy that was never modelled, so diff each route's visible text against its entry values yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for (null when unknown: runtime release, >500 routes, or a report older than this field). `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge` (the build ships BcmsDraftBridge), `draft-route` (a node runtime rendering drafts server-side) or `none`, in which case get_next_steps carries the recipe (playbook section 11); null means the report predates the field. `unaddressable` counts visible text on the live site that no field owns \u2014 the one thing `unmatched` structurally cannot see, because it only ever speaks about fields that already exist. EVERY release measures it server-side on every inspected page: `{count, pages, measuredAt, routes:[{path, count, visible, buckets:[{tag, context, chars, nodes, samples}]}]}`, where `path` is the route (the empty string is the home page), `visible` its countable characters (ornaments, skip links and the platform badge excluded from both sides) and `buckets` where the unowned text is, biggest first. Anything over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11 has the loop. An author turning the editor's Unbound text control on posts a bare count of text NODES for the route they opened (no `visible`, no buckets); those rows are reported only for a build the release never measured, never summed with the release's characters. `skipReasons` says why pages were not inspected (`no-html`, `route-cap`, `authored-content` = the singleton lane declined the project, so NO route of it has a page), and `coverage.error` = `nothing-bound` means not one element of this build carries a binding, so the percentage beside it describes a site this build is not. `unaddressable` is measured PER SLOT; on a promote-gated project pass slot:'current' to read the tree the editor frames. Pass `slot` ('current' or 'staging') to read the other tree; the default is the slot this project's releases land in.",
3102
3233
  z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read; defaults to the one this project deploys to") }).shape,
3103
3234
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
3104
3235
  ),
@@ -3379,6 +3510,8 @@ function buildToolDefs(deps) {
3379
3510
  slug: args.slug,
3380
3511
  pageType: args.pageType ?? "singleton",
3381
3512
  ...args.blockJson ? { blockJson: args.blockJson } : {},
3513
+ // Cast: the SDK's ContentModelField types `options` as string[], narrower than the API,
3514
+ // which also takes {label, value} (backend selectFieldSchema).
3382
3515
  ...args.fields ? { fields: args.fields.map(toField) } : {},
3383
3516
  ...args.metaTitle !== void 0 ? { metaTitle: args.metaTitle } : {},
3384
3517
  ...args.metaDescription !== void 0 ? { metaDescription: args.metaDescription } : {}
@@ -3394,7 +3527,7 @@ function buildToolDefs(deps) {
3394
3527
  name: "create_content_model",
3395
3528
  config: {
3396
3529
  title: "Create a content model (reusable schema)",
3397
- description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). `fields` may NEST: type 'group' = one nested object of child fields; type 'repeater' = a repeatable array of child objects. Put child fields in each group/repeater's own `fields` (any depth). Pass kind:'block' to create a BLOCK type instead \u2014 see that argument.",
3530
+ description: "Create a content model \u2014 a reusable schema for a dynamic collection (Blog, Products, Testimonials). `fields` may NEST: type 'group' = one nested object of child fields; type 'repeater' = a repeatable array of child objects. Put child fields in each group/repeater's own `fields` (any depth). Pass kind:'block' to create a BLOCK type instead \u2014 see that argument. `fieldsets` ([{id, name}]) are the model's editor cards; a field joins one with `fieldsetId`. The slug must be unique in this project and branch: a taken one is refused with 409 `slug_taken` and free `suggestions`. " + RICH_TEXT_NOTE,
3398
3531
  inputSchema: createModelInput.shape
3399
3532
  },
3400
3533
  handler: guard(
@@ -3405,7 +3538,9 @@ function buildToolDefs(deps) {
3405
3538
  name: args.name,
3406
3539
  slug: args.slug,
3407
3540
  ...args.description !== void 0 ? { description: args.description } : {},
3541
+ ...args.urlPattern !== void 0 ? { urlPattern: args.urlPattern } : {},
3408
3542
  ...args.kind !== void 0 ? { kind: args.kind } : {},
3543
+ ...args.fieldsets !== void 0 ? { fieldsets: args.fieldsets } : {},
3409
3544
  fields: toFields(args.fields)
3410
3545
  });
3411
3546
  return ok(
@@ -3419,7 +3554,7 @@ function buildToolDefs(deps) {
3419
3554
  name: "add_field",
3420
3555
  config: {
3421
3556
  title: "Add a field to a content model",
3422
- description: "Append a field to an existing content model. Reads the model's current fields and adds yours (read-modify-write) \u2014 never removes existing fields. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields.",
3557
+ description: "Append a field to an existing content model. Reads the model's current fields and adds yours (read-modify-write) \u2014 never removes existing fields. For a section/zone, add ONE 'group' (fixed block) or 'repeater' (repeating list) field carrying its child `fields` \u2014 don't add the zone's inner fields as separate top-level fields. `fieldsetId` puts it in one of the model's fieldsets (set them with update_content_model). " + RICH_TEXT_NOTE,
3423
3558
  inputSchema: addFieldInput.shape
3424
3559
  },
3425
3560
  handler: guard(
@@ -3427,11 +3562,26 @@ function buildToolDefs(deps) {
3427
3562
  const model = await client.getModel(args.modelId);
3428
3563
  const dupes = duplicateKeys([...flatFieldKeys(model.fields), args.key]);
3429
3564
  if (dupes.length > 0) return fail(duplicateKeyFailure(dupes));
3565
+ const existingFields = model.fields;
3566
+ let at = existingFields.length;
3567
+ if (args.after !== void 0) {
3568
+ const idx = existingFields.findIndex(
3569
+ (f) => f.key === args.after
3570
+ );
3571
+ if (idx === -1) {
3572
+ return fail(
3573
+ `after: '${args.after}' is not a top-level field on this model. Valid keys: ${existingFields.map((f) => `'${f.key}'`).join(", ")}. Omit \`after\` to append at the end.`
3574
+ );
3575
+ }
3576
+ at = idx + 1;
3577
+ }
3578
+ const nextFields = [...existingFields];
3579
+ nextFields.splice(at, 0, toField(args));
3430
3580
  const updated = await client.updateModel(args.modelId, {
3431
- fields: [...model.fields, toField(args)]
3581
+ fields: nextFields
3432
3582
  });
3433
3583
  return ok(
3434
- `Added field '${args.key}' to '${updated.name}'. Model now has ${updated.fields.length} field(s).`,
3584
+ `Added field '${args.key}' to '${updated.name}'${args.after ? ` after '${args.after}'` : ""}. Model now has ${updated.fields.length} field(s).`,
3435
3585
  updated
3436
3586
  );
3437
3587
  })
@@ -3446,7 +3596,12 @@ function buildToolDefs(deps) {
3446
3596
  },
3447
3597
  handler: guard(
3448
3598
  async (args) => withClient(async (client) => {
3449
- const page = await client.addPageFields(args.pageId, { addFields: [toField(args)] });
3599
+ const page = await client.addPageFields(args.pageId, {
3600
+ // Cast: same as create_page. The SDK types `options` as string[], narrower than the
3601
+ // API, which also takes {label, value} (backend selectFieldSchema).
3602
+ addFields: [toField(args)],
3603
+ ...args.after !== void 0 ? { after: args.after } : {}
3604
+ });
3450
3605
  return ok(
3451
3606
  `Added field '${args.key}' to page '${page.title}'. Page now has ${page.fields.length} field(s).`,
3452
3607
  page
@@ -3764,7 +3919,7 @@ function buildToolDefs(deps) {
3764
3919
  name: "get_component",
3765
3920
  config: {
3766
3921
  title: "Get a component (with its blockJson)",
3767
- description: "Get one component by id INCLUDING its blockJson tree and props. Read this before update_component so you keep the existing blocks.",
3922
+ description: "Get one component by id INCLUDING its blockJson tree and props (with any stored `ui` authoring chrome on them). Read this before update_component so you keep the existing blocks, and after declaring field UI as the receipt that it landed \u2014 a prop absent here was not stored.",
3768
3923
  inputSchema: getComponentInput.shape
3769
3924
  },
3770
3925
  handler: guard(
@@ -3967,6 +4122,61 @@ ${lines.join("\n")}`, found);
3967
4122
  })
3968
4123
  )
3969
4124
  },
4125
+ {
4126
+ name: "set_component_source",
4127
+ config: {
4128
+ title: "Record which file implements a component",
4129
+ description: "Call this RIGHT AFTER you write or locate the code for a component in the app's repository. It tells BetterCMS which file renders the component, so the dashboard's Output button can build a live preview and validate it with no setup. Without it Output says no source is recorded and nothing is validated. Record ONLY a file that renders exactly this component from its own fields: a component whose props are this component's field keys (`kind: 'file'`, the default), or a section the codemod extracted with `npx @bettercms-ai/convert --componentize` \u2014 a file starting `// @bettercms-ai/convert section` whose props are `{ blockId, bind, overrides, page }` (`kind: 'section'`). Never record a different component that merely contains this markup (e.g. a card that needs a `post` object): it cannot render from these fields and validation fails. If the markup is inline in a page, extract it into a section first. `path` is relative to the app root (e.g. 'src/components/sections/Hero.astro'); `export` is the export name, omitted for a default export. Call it again if the file moves; call clear_component_source if a recorded file is wrong.",
4130
+ inputSchema: setComponentSourceInput.shape
4131
+ },
4132
+ handler: guard(
4133
+ async (args) => withClient(async (client) => {
4134
+ const res = await client.fetchJSON(
4135
+ client.url(`/management/components/${encodeURIComponent(args.componentId)}/preview-source`),
4136
+ { method: "PUT", body: JSON.stringify({ path: args.path, ...args.export ? { export: args.export } : {}, ...args.kind ? { kind: args.kind } : {} }) }
4137
+ );
4138
+ return ok(`Recorded ${res.data.path}${res.data.export === "default" ? "" : ` (export ${res.data.export})`} as the source of component ${res.data.componentId}.`, res.data);
4139
+ })
4140
+ )
4141
+ },
4142
+ {
4143
+ name: "submit_componentize_receipt",
4144
+ config: {
4145
+ title: "Record the sources a componentize run wrote",
4146
+ description: "Record every section the componentize codemod extracted as its component's source. Call it right after `npx @bettercms-ai/convert --componentize --receipt componentize.json` (and after pushing the files): pass that receipt verbatim. Each extracted section is recorded as kind 'section' for the component with the same slug, so Output can build and validate it with no set_component_source per component. Answers which components were recorded, and any slug this project has no component for.",
4147
+ inputSchema: submitComponentizeReceiptInput.shape
4148
+ },
4149
+ handler: guard(
4150
+ async (args) => withClient(async (client) => {
4151
+ const res = await client.fetchJSON(
4152
+ client.url("/management/components/preview-sources/componentize-receipt"),
4153
+ { method: "POST", body: JSON.stringify({ receipt: args.receipt }) }
4154
+ );
4155
+ const { recorded, unknown, invalid } = res.data;
4156
+ return ok(
4157
+ `Recorded ${recorded.length} section source${recorded.length === 1 ? "" : "s"}${unknown.length ? `; no component for ${unknown.join(", ")}` : ""}${invalid.length ? `; unimportable path for ${invalid.join(", ")}` : ""}.`,
4158
+ res.data
4159
+ );
4160
+ })
4161
+ )
4162
+ },
4163
+ {
4164
+ name: "clear_component_source",
4165
+ config: {
4166
+ title: "Remove a component's recorded source file",
4167
+ description: "Remove the file recorded as a component's source (by set_component_source or a conversion receipt). Use it when the recorded file is wrong \u2014 Output then shows no source until the right file is recorded. Safe to call when nothing is recorded.",
4168
+ inputSchema: clearComponentSourceInput.shape
4169
+ },
4170
+ handler: guard(
4171
+ async (args) => withClient(async (client) => {
4172
+ const res = await client.fetchJSON(
4173
+ client.url(`/management/components/${encodeURIComponent(args.componentId)}/preview-source`),
4174
+ { method: "DELETE" }
4175
+ );
4176
+ return ok(res.data.cleared ? `Removed the recorded source of component ${res.data.componentId}.` : `Component ${res.data.componentId} had no recorded source.`, res.data);
4177
+ })
4178
+ )
4179
+ },
3970
4180
  {
3971
4181
  name: "publish_component",
3972
4182
  config: {
@@ -4013,6 +4223,55 @@ ${lines.join("\n")}`, found);
4013
4223
  })
4014
4224
  )
4015
4225
  },
4226
+ // ── Native bridge (executor presence) ──────────────────────────────────────
4227
+ {
4228
+ name: "connect_native_ai",
4229
+ config: {
4230
+ title: "Connect this client to the project's Native AI Connection",
4231
+ description: "Connect this client to the project's Native AI Connection, so the dashboard shows it as an available executor. Call it ONCE when the user asks to connect \u2014 after that, ordinary tool calls on the same project keep it listed, and nothing needs to run on a timer. Idempotent. Returns the project it connected to and `presenceWindowMs`: the executor stays listed for that long after the last tool call, then drops off until the next one. THIS GRANTS NOTHING \u2014 it is presence only, and starting a run is still a human act in the dashboard. A 403 BRIDGE_GRANT_REQUIRED means this client is using a bare API key rather than an approved connection; a 400 means the connection covers the whole workspace and you must pass `projectId`.",
4232
+ inputSchema: connectNativeAiInput.shape
4233
+ },
4234
+ handler: guard(
4235
+ async (args) => withClient(async (client) => {
4236
+ const result = await client.fetchJSON(client.url("/management/orchestration/bridge/connect"), {
4237
+ method: "POST",
4238
+ body: JSON.stringify({
4239
+ protocolVersion: "1",
4240
+ ...args.capabilities ? { capabilities: args.capabilities } : {}
4241
+ })
4242
+ });
4243
+ const mins = result.presenceWindowMs ? Math.round(result.presenceWindowMs / 6e4) : null;
4244
+ return ok(
4245
+ `Connected to project ${result.projectId}. The dashboard now lists this client as an available executor${mins ? `; it stays listed for ${mins} minutes after the last tool call` : ""}.`,
4246
+ result
4247
+ );
4248
+ })
4249
+ )
4250
+ },
4251
+ {
4252
+ name: "native_bridge_heartbeat",
4253
+ config: {
4254
+ title: "Register as a native bridge and report liveness",
4255
+ description: "Register this client as a native-bridge executor for the connected project and report that it is alive. The dashboard's 'Native AI Connection' panel shows an executor as live ONLY while heartbeats keep arriving: call this again every `nextHeartbeatMs` (25s), because liveness lapses 90s after the last call and the panel then stops offering this executor. Idempotent \u2014 registration happens on the first call, so a restart or a reconnect is just another call. Echo back the `session` from the previous response so the same connection epoch is kept; omit it to reconnect, which fences the previous one. A 422 names a refused capability or protocol; it does not retry into success. THIS GRANTS NO REPOSITORY MUTATION \u2014 it is presence only, and starting a run still requires a human in the dashboard.",
4256
+ inputSchema: nativeBridgeHeartbeatInput.shape
4257
+ },
4258
+ handler: guard(
4259
+ async (args) => withClient(async (client) => {
4260
+ const result = await client.fetchJSON(client.url("/management/orchestration/bridge/heartbeat"), {
4261
+ method: "POST",
4262
+ body: JSON.stringify({
4263
+ protocolVersion: args.protocolVersion,
4264
+ capabilities: args.capabilities,
4265
+ ...args.session ? { session: args.session } : {}
4266
+ })
4267
+ });
4268
+ return ok(
4269
+ `Bridge ${result.liveness}. Call again within ${Math.round(result.nextHeartbeatMs / 1e3)}s to stay live.`,
4270
+ result
4271
+ );
4272
+ })
4273
+ )
4274
+ },
4016
4275
  // ── Lifecycle tools (parity with the remote /mcp surface) ──────────────────
4017
4276
  // Media management, form submissions (leads), redirects, SEO, AEO site-files,
4018
4277
  // promote, and entry version history. These call the management endpoints straight
@@ -4324,10 +4583,28 @@ lane, so every structural draft falls back to the platform renderer and its gene
4324
4583
  \`null\` means the report predates the field; redeploy to learn. When it is \`none\`,
4325
4584
  \`get_next_steps\` names the recipe for this project's framework (\`canvas-lane-missing\`), and
4326
4585
  \xA713 step 5c has both. The same report carries \`unaddressable\`: how much VISIBLE text on the
4327
- live site no field owns, counted against the sha now serving when an author turns the visual
4328
- editor's **"Unbound text"** control on \u2014 \`unmatched\` cannot see it, because it only ever speaks
4329
- about fields that already exist. Absent means nobody has turned that control on for this build,
4330
- not that the site is clean.
4586
+ live site no field owns \u2014 \`unmatched\` cannot see it, because it only ever speaks about fields
4587
+ that already exist. EVERY release measures it server-side, per route, with a denominator and the
4588
+ places it is (\`unaddressable.routes[]\`: \`path\`, \`count\`, \`visible\`, \`buckets[]\`). An author
4589
+ turning the editor's **"Unbound text"** control on posts a bare count of text NODES for the route
4590
+ they opened; it is reported only for a build the release never measured, and never added to
4591
+ the release's characters. Ornaments, skip links and the platform badge are excluded from both sides.
4592
+
4593
+ **THE LOOP, and it is not optional \u2014 a deploy is not a conversion.**
4594
+
4595
+ 1. deploy, then \`get_binding_report\`;
4596
+ 2. for each \`unaddressable.routes[]\` where \`count / visible > 0.02\`:
4597
+ read its \`buckets\` \u2014 each one is a TAG in a LANDMARK with a sample of the copy;
4598
+ 3. fix it in the SOURCE: wrap that run of text in an element the codemod can bind (a
4599
+ \`<span>\`, a \`<p>\`, a \`<div>\` \u2014 never a bare text node between tags), or declare it
4600
+ yourself with \`<BcmsField path="\u2026">\`;
4601
+ 4. deploy again and re-read. Repeat until every route is under 2%.
4602
+
4603
+ \`get_next_steps\` runs the same test and hands you the worst route with its buckets
4604
+ (\`unaddressable-copy\`). A \`coverage\` carrying \`error: "nothing-bound"\` means NOT ONE element
4605
+ of the build is bound \u2014 usually the singleton lane declined the project (\`skipReasons\`
4606
+ \`authored-content\`: its content models were authored by the repo), so the percentage beside it
4607
+ describes a site this build is not.
4331
4608
 
4332
4609
  **A site built in the CMS with NO deployed build has only that renderer** \u2014 live and in the
4333
4610
  editor \u2014 so the generic theme IS the site until you theme it. Theme it without a deploy:
@@ -4468,6 +4745,19 @@ cluster \u2192 \`group\`. Nesting is capped at TWO levels from the prop (a \`tab
4468
4745
  utilities, aria attributes and alt text all survive. Never restyle while componentizing. Run
4469
4746
  \`componentize_sections { dryRun: true }\` and the codemod's \`--dry-run\` first.
4470
4747
 
4748
+ **5b. Every component's Output must render \u2014 record its SOURCE.** The dashboard's Output builds and
4749
+ validates a component from the file in the repository that renders it, and only a file whose props
4750
+ match renders it. A section the codemod extracted is such a file: after \`npx @bettercms-ai/convert
4751
+ --componentize --receipt componentize.json\`, push the files and call \`submit_componentize_receipt
4752
+ { receipt }\` \u2014 every extracted section is recorded for the component with the same slug. A section
4753
+ you write BY HAND follows the same contract: props exactly \`{ blockId, bind, overrides, page }\`, the
4754
+ root element carries \`data-bcms-block={blockId}\`, and copy is read through \`bcmsSection(page, bind,
4755
+ overrides)\` (or \`bcmsSectionPaths\` for a flat field family like \`wrap-h2-latest\`); then call
4756
+ \`set_component_source { kind: 'section' }\`. A component module whose props ARE its fields is
4757
+ \`set_component_source\` with the default kind. NEVER record a different component that merely
4758
+ contains the markup (a card needing a \`post\` object): it cannot render from these fields and its
4759
+ validation fails every time \u2014 \`clear_component_source\` removes a wrong one.
4760
+
4471
4761
  **6. The final validation is \`get_site_composition\`.** It is the receipt for "is every page
4472
4762
  assembled from registered components?": per page its blocks and every component placement with
4473
4763
  that component's status, plus site totals \u2014 unpublished, awaiting evidence, awaiting approval,
@@ -4482,6 +4772,8 @@ bulk approve, and no tool here can grant that approval. Finish with \`get_next_s
4482
4772
  1. get_site_composition the before picture
4483
4773
  2. get_componentize_plan DERIVED site only \u2014 confirm it with the user
4484
4774
  3. componentize_sections dryRun first, then for real
4775
+ 3b. npx @bettercms-ai/convert --componentize --receipt componentize.json, push,
4776
+ then submit_componentize_receipt { receipt } every section's Output source, ONE call
4485
4777
  4. create_components everything the plan did not cover, ONE call, each with a group
4486
4778
  5. compose_pages every page's blockJson, ONE call
4487
4779
  6. update_layout { commands } nav/footer chrome + add-component, ONE call
@@ -4694,6 +4986,123 @@ conversion is yours to write.
4694
4986
  **Done means.** Whichever recipe you ran: \`get_binding_report\` reads \`mode "declared"\`,
4695
4987
  \`unmatched\` empty, \`bound\` above 0, \`coverage.pending\` empty, and \`canvas.lane\` is \`bridge\` or
4696
4988
  \`draft-route\` when the site should show structural drafts.
4989
+
4990
+ ## 14. Declare how a field LOOKS in the editor \u2014 \`ui\`, \`helpText\`, \`showIf\`
4991
+
4992
+ You can create the schema. You can also say how it PRESENTS to the person filling it in. These
4993
+ three props ride on any field, on any field-creating tool (\`create_content_model\`,
4994
+ \`create_page\`, \`add_field\`, \`add_page_field\`), and none of them can move, reshape or hide a
4995
+ delivered value \u2014 they change editor chrome only.
4996
+
4997
+ \`\`\`jsonc
4998
+ { "key": "testimonials", "label": "Testimonials", "type": "repeater",
4999
+ "ui": { "collapsed": true, "preview": { "title": "author", "media": "avatar" },
5000
+ "layout": "grid", "reorderable": true },
5001
+ "helpText": "Three to five. Shortest quotes read best.",
5002
+ "fields": [ { "key": "author", ... }, { "key": "avatar", "type": "image", ... }, ... ] }
5003
+ \`\`\`
5004
+
5005
+ - **\`helpText\`** \u2014 one line UNDER the field telling the author what to write here, not what the
5006
+ field is. "Roughly 60 characters; it becomes the browser tab title" earns its place.
5007
+ "The title field" does not.
5008
+ - **\`showIf\`** \u2014 hide a field until another has a value:
5009
+ \`{match:'all',rules:[{field:'link_type',op:'eq',value:'external'}]}\`. Rules name fields by
5010
+ BARE KEY, and the key namespace is FLAT across the model (\xA7 the duplicate-key rule), so the
5011
+ key you reference must exist.
5012
+ - **\`ui.collapsed\`** \u2014 start a nesting field's panel closed. For long optional sections.
5013
+ - **\`ui.preview\`** \u2014 how ONE ITEM of a nesting field summarises itself when collapsed. Both
5014
+ slots name a **CHILD FIELD KEY of that field** \u2014 not a value, not a dotted path.
5015
+ Without it a repeater of ten testimonials reads "Item 1 \u2026 Item 10" and the author has to open
5016
+ each one to find the one they meant. \`media\` may point at an \`image\`, \`file\` or url-ish
5017
+ \`text\` child; the type is not constrained.
5018
+ - **\`ui.layout\`** \u2014 \`'list'\` (the default; omit it) or \`'grid'\`. Grid when the THUMBNAIL is
5019
+ what the author scans: a gallery, a logo wall, a team. List when the words are.
5020
+ - **\`ui.reorderable\`** \u2014 omit it (\u2261 \`true\`) unless the ORDER IS PART OF THE MEANING. \`false\`
5021
+ LOCKS the list so its items cannot be dragged: a "three steps" band that must stay three steps
5022
+ in that order, a nav whose order is semantic, a timeline. Locking a list nobody should reorder
5023
+ is the declaration there was previously no way to make; locking one out of tidiness takes a
5024
+ capability away from the author, so do not set it "just in case".
5025
+
5026
+ **Five refusals, all deliberate, all 400 with the fix in the message.**
5027
+ 1. An unknown key inside \`ui\` is REJECTED, not stripped. \`ui\` is a strict object precisely so
5028
+ that a typo is a refusal you can read instead of a 200 with your declaration gone.
5029
+ 2. \`ui.preview\` on a LEAF field is refused \u2014 it summarises an item, and a leaf has no items.
5030
+ Valid on \`group\`, \`repeater\`, an \`array\` with \`config.zones\`, or \`modular\`.
5031
+ 3. \`preview.title\` / \`preview.media\` naming a key that is not a child of that field is refused,
5032
+ and the error lists the valid child keys.
5033
+ 4. \`ui.layout\` follows the same rule as \`preview\`: nesting fields only. A leaf has no items
5034
+ to arrange.
5035
+ 5. \`ui.reorderable\` is STRICTER \u2014 it needs a LIST, not merely children. Valid on \`repeater\`,
5036
+ an \`array\` with \`config.zones.repeatable\`, or \`modular\`. A \`group\` (and a non-repeatable
5037
+ zone) holds exactly ONE item, so there is nothing there to drag and the write is refused.
5038
+
5039
+ **The same \`ui\` rides on COMPONENT PROPS.** \`create_component\` / \`update_component\` take it on
5040
+ each entry of \`props\`, with the placement rules the prop vocabulary implies: \`preview\` and
5041
+ \`layout\` on \`group\`, \`table\` and \`slot\`; \`reorderable\` on \`table\` ALONE, because a table is the
5042
+ only prop whose value is a list (a \`group\` is one object, a \`slot\` one component instance). A
5043
+ prop's \`preview\` keys name a SUB-FIELD from its own \`config.fields\` and are checked against
5044
+ them \u2014 except on a \`slot\`, whose children belong to whichever component fills it and therefore
5045
+ cannot be resolved at save time. \`update_component\` REPLACES the whole \`props\` array, so echo
5046
+ back each prop's stored \`ui\` or you delete it along with the prop.
5047
+
5048
+ \`\`\`jsonc
5049
+ { "key": "slides", "label": "Slides", "type": "table",
5050
+ "target": { "blockId": "slider", "path": "props.slides" },
5051
+ "ui": { "preview": { "title": "caption", "media": "image" }, "layout": "grid" },
5052
+ "config": { "fields": [ { "key": "image", "type": "image", ... },
5053
+ { "key": "caption", "type": "text", ... } ] } }
5054
+ \`\`\`
5055
+
5056
+ **THE RECEIPT \u2014 read it back.** There is no separate verification tool and no mode to flip.
5057
+ \`get_content_model\` (and \`get_page\` / \`get_component\`) return each field's \u2014 and each component
5058
+ prop's \u2014 stored \`ui\`, \`helpText\` and \`showIf\`. A prop that is ABSENT from that response was not
5059
+ stored, whatever the write returned \u2014 \xA712 rule 3, applied to this feature. Declare, then read
5060
+ back, then say it works.
5061
+
5062
+ **No \`order\` property. Ever.** A field's order IS its index in \`fields\`. To put a new field
5063
+ somewhere other than the end, pass \`after: '<existing top-level field key>'\` to \`add_field\` /
5064
+ \`add_page_field\`; omit it and the field appends, exactly as before. \`after\` is an argument
5065
+ applied once at the write \u2014 a stored ordering prop would be a second source of truth that
5066
+ disagrees with the array the moment anyone drags a row in the builder.
5067
+
5068
+ **Where the values come from \u2014 three tiers, in order. Stop at the first that answers.**
5069
+
5070
+ TIER 1 DERIVE FROM THE SITE'S SOURCE, when you have a checkout.
5071
+ <img src={item.image}> + <h3>{item.name}</h3> inside the map over \`team\`
5072
+ \u2192 ui.preview = { title: 'name', media: 'image' }
5073
+ A section rendered inside a <details> or behind a "Show advanced" toggle
5074
+ \u2192 ui.collapsed: true
5075
+ grid-cols-* / display:grid on the wrapper of the .map \u2192 ui.layout: 'grid'
5076
+ A FIXED-ARITY render \u2014 steps[0]/steps[1]/steps[2], or copy that names the
5077
+ positions ("Step 1", "then", "finally") \u2192 ui.reorderable: false
5078
+ A prop read only when another is set \u2192 showIf
5079
+ \u{1F534} THE SERVER NEVER READS YOUR SOURCE on the add_field path. \`pull_project_source\`
5080
+ and \`get_conversion_brief\` hand YOU the repo; this tier is work you do in your own
5081
+ checkout before you call the tool. On a greenfield, schema-first project there is
5082
+ no source at all \u2014 Tier 1 is vacuous and TIER 2 IS THE FLOOR.
5083
+
5084
+ TIER 2 DERIVE FROM THE FIELD SHAPE, when the code is silent.
5085
+ A repeater whose children include exactly one image-ish child \u2192 that is \`media\`.
5086
+ Its first required short-text child (name/title/heading/label) \u2192 that is \`title\`.
5087
+ A nesting field with more than ~6 children, or one whose label reads as optional
5088
+ ("Advanced", "Extras", "SEO overrides") \u2192 \`collapsed: true\`.
5089
+ A repeatable whose item is MOSTLY its image (gallery, logos, team) \u2192 \`layout:
5090
+ 'grid'\`; anything whose words are the point stays 'list' \u2014 so OMIT \`layout\`.
5091
+ \`reorderable\` HAS NO TIER-2 GUESS. The default (true) is the safe one, and
5092
+ locking a list the user did not ask to lock takes a capability away from them.
5093
+ Leave it absent unless Tier 1 showed you a fixed arity or the user said so.
5094
+ Guessing here is CORRECT. A wrong preview costs one edit; asking about every
5095
+ repeater on a 40-field schema costs the user the session.
5096
+
5097
+ TIER 3 ASK THE USER \u2014 only when 1 and 2 are both silent, and only for these three:
5098
+ a) MANY fields in one panel, where the grouping and the order depend on which
5099
+ content the user considers primary. You cannot know their priorities.
5100
+ b) CONDITIONAL VISIBILITY (\`showIf\`). It encodes business logic \u2014 when a field is
5101
+ irrelevant \u2014 and nothing in the code or the shape tells you that.
5102
+ c) VARIANTS SHARING A \`sectionType\`. Their prop keys MUST match (\xA74), so a preview
5103
+ or a rename that diverges between two variants makes a layout swap lose content.
5104
+ Confirm the shared key set before you declare per-variant chrome.
5105
+ Ask with AskUserQuestion, batched, once \u2014 never one field at a time.
4697
5106
  `;
4698
5107
 
4699
5108
  // src/prompts.ts
@@ -5199,6 +5608,108 @@ On 401/403, the MCP key needs (re)authorizing.`
5199
5608
  ]
5200
5609
  })
5201
5610
  );
5611
+ server.registerPrompt(
5612
+ "field-ui",
5613
+ {
5614
+ title: "Declare how fields LOOK in the editor (guided)",
5615
+ description: "Set the authoring chrome on an existing schema \u2014 collapsed panels, what titles and thumbnails a repeater's rows, help text, conditional visibility. Derives it from your site's source where there is source, from the field shapes where there isn't, and only asks you about the three things it genuinely cannot know.",
5616
+ argsSchema: {
5617
+ request: z2.string().optional().describe("scope, e.g. 'the Team and Testimonials models' or 'the whole project'")
5618
+ }
5619
+ },
5620
+ ({ request }) => ({
5621
+ messages: [
5622
+ {
5623
+ role: "user",
5624
+ content: {
5625
+ type: "text",
5626
+ text: `Declare the editor UI for my BetterCMS fields.${request ? ` Scope: "${request}".` : ""}
5627
+
5628
+ Read bettercms://playbook/schema section 14 first \u2014 it has the exact prop shapes and the refusals.
5629
+
5630
+ **What you are setting** (all of these ride on any field, on create_content_model / create_page /
5631
+ add_field / add_page_field; none of them affect delivery):
5632
+ ui.collapsed start a nesting field's panel closed
5633
+ ui.preview {title,media} which CHILD FIELD KEY titles a collapsed row, and which is its thumb
5634
+ ui.layout 'list'|'grid' how its items are arranged; omit for list
5635
+ ui.reorderable omit (= true) unless the ORDER IS THE MEANING; false LOCKS the list
5636
+ helpText one line under the field: what to WRITE here
5637
+ showIf hide a field until another field has a value
5638
+
5639
+ **The same \`ui\` rides on COMPONENT PROPS** \u2014 create_component / update_component, one per entry
5640
+ of \`props\`. Placement differs because the vocabulary does: preview/layout on 'group', 'table'
5641
+ and 'slot'; reorderable on 'table' ALONE (the only prop whose value is a list). A prop's preview
5642
+ keys name a SUB-FIELD from its own config.fields. If this scope includes components, do them in
5643
+ the same pass \u2014 but note update_component REPLACES the whole props array, so read get_component
5644
+ first and echo every prop back WITH its stored ui.
5645
+
5646
+ **Work the three tiers in order. Stop at the first that answers \u2014 do not escalate what a tier
5647
+ below already settled.**
5648
+
5649
+ TIER 1 \u2014 DERIVE FROM SOURCE, if and only if you have a checkout of the site.
5650
+ Read the component that renders each field. \`<img src={item.photo}>\` next to
5651
+ \`<h3>{item.name}</h3>\` in the map over a list IS the answer: preview {title:'name',
5652
+ media:'photo'}. A block inside \`<details>\` or behind a "Show more" is collapsed:true.
5653
+ A \`grid-cols-*\` wrapper around the map is layout:'grid'. A FIXED-ARITY render \u2014
5654
+ steps[0]/steps[1]/steps[2], or copy that names positions ("Step 1", "finally") \u2014 is
5655
+ reorderable:false, and it is the ONLY evidence that earns that key.
5656
+ \u{1F534} THE SERVER NEVER READS YOUR SOURCE. \`pull_project_source\` / \`get_conversion_brief\` hand
5657
+ the repo to YOU; add_field does not look at it. This tier is your own reading, in your own
5658
+ checkout, before you call any tool. ON A GREENFIELD SCHEMA-FIRST PROJECT THERE IS NO SOURCE \u2014
5659
+ Tier 1 does not apply and TIER 2 IS THE FLOOR. Do not stall waiting for code that will
5660
+ never exist.
5661
+
5662
+ TIER 2 \u2014 DERIVE FROM THE FIELD SHAPE, when the code is silent or absent.
5663
+ One image-ish child in a repeater \u2192 that is \`media\`. The first required short-text child
5664
+ (name/title/heading/label) \u2192 that is \`title\`. More than ~6 children, or a label that reads
5665
+ optional ("Advanced", "Extras") \u2192 collapsed:true. A repeatable whose item is mostly its
5666
+ image (gallery, logos, team) \u2192 layout:'grid'; everything else stays list, so OMIT layout.
5667
+ \`reorderable\` has NO Tier-2 guess \u2014 leave it absent. The default is true, and locking a
5668
+ list the user did not ask to lock silently removes something they could do yesterday.
5669
+ GUESS HERE otherwise. A wrong preview costs one edit; a question per repeater costs the
5670
+ user the session.
5671
+
5672
+ TIER 3 \u2014 ASK ME, and ONLY for these three. Batch them into ONE AskUserQuestion at the end,
5673
+ never one field at a time:
5674
+ a) A panel with MANY fields, where grouping and order depend on which content I treat as
5675
+ primary \u2014 you cannot infer my priorities.
5676
+ b) CONDITIONAL VISIBILITY (showIf) \u2014 it is business logic about when a field is irrelevant.
5677
+ Never invent a rule.
5678
+ c) VARIANTS SHARING A sectionType \u2014 their prop keys must match, or swapping the layout loses
5679
+ content. Confirm the shared key set before declaring per-variant chrome.
5680
+
5681
+ **Order of work**
5682
+ 1. \`list_content_models\` / \`list_pages\`, then \`get_content_model\` / \`get_page\` for each target.
5683
+ Note which fields ALREADY carry ui/helpText/showIf \u2014 do not overwrite a human's choice
5684
+ without asking.
5685
+ 2. Decide each field's chrome by the tiers above. Write down which tier answered; you will
5686
+ report it.
5687
+ 3. Apply. For a field that already exists, the declaration goes with the field: use the
5688
+ dashboard or a model update \u2014 \`add_field\` APPENDS and refuses an existing key, so it is
5689
+ the wrong tool for editing one. For a NEW field, pass ui/helpText/showIf in the same
5690
+ \`add_field\` / \`add_page_field\` call, and pass \`after: '<existing key>'\` if it belongs
5691
+ somewhere other than the bottom.
5692
+ 4. **RECEIPT \u2014 non-negotiable.** Re-read every target with \`get_content_model\` / \`get_page\` /
5693
+ \`get_component\` and
5694
+ confirm the stored \`ui\` matches what you sent. A prop ABSENT from that response was NOT
5695
+ stored, whatever the write said. Report per field: the tier that decided it, what you set,
5696
+ and that the read-back confirmed it. Anything you could not confirm, say so plainly.
5697
+
5698
+ **Refusals you should expect and must not work around:** an unknown key inside \`ui\` is a 400
5699
+ (it is strict on purpose \u2014 that is your typo, named); \`ui.preview\` or \`ui.layout\` on a leaf
5700
+ field is a 400 (both describe ITEMS); \`ui.reorderable\` anywhere without a LIST is a 400 \u2014 a
5701
+ \`group\` and a non-repeatable zone hold one item, so only \`repeater\`, an \`array\` with
5702
+ config.zones.repeatable, and \`modular\` take it; a preview key that is not a child of that field
5703
+ is a 400 listing the valid child keys. Fix the declaration; never retry without it and call that
5704
+ success.
5705
+
5706
+ There is deliberately NO \`order\` property and no mode to turn on \u2014 order is the array index,
5707
+ and \`ui\` only changes editor chrome, so there is nothing to flip.`
5708
+ }
5709
+ }
5710
+ ]
5711
+ })
5712
+ );
5202
5713
  }
5203
5714
 
5204
5715
  // src/server.ts