@bettercms-ai/mcp 0.50.0 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,6 +16,19 @@ import { z } from "zod";
16
16
 
17
17
  // ../types/src/component.ts
18
18
  var SECTION_DOCTRINE = "STRUCTURE (separate from schema): a page is composed of SECTIONS. NEVER build a page out of loose top-level heading/text/image/button/spacer blocks \u2014 they cannot be moved, duplicated or swapped as a unit, the visual editor cannot outline or name them, and every one of them becomes its own section in the editor. A hero of a headline, a lede and two CTAs is ONE section, not four. TWO SHAPES, and the choice is about REUSE. (1) A band that appears on more than one page, or that needs layout variants, is a COMPONENT with a `sectionType` \u2014 see create_component. Components sharing a `sectionType` are that section's VARIANTS (one Hero: 'Centered' for the home page and 'Two-column' for about, same prop keys so a swap keeps the content). This is also the only shape the editor's 'Add a section' picker can insert, and the only one that gets a family name and a variant switcher. (2) A genuinely one-off band on a single page is a `section` BLOCK whose `props.children` hold its blocks. THE TRADEOFF, stated in the present tense because it is real today: inside a component, ONLY the leaves a declared prop TARGETS are click-to-edit on the canvas \u2014 each declared prop's `target` is re-keyed to that PLACEMENT's own override address, so editing it changes this page and not the shared definition. A prop with no `target` still SHOWS as a dock control, but its override reaches no rendered slot, so editing it changes nothing on the page. Copy that no prop points at is worse still: no binding and no control, unreachable from click-to-edit AND from the dock, changeable only by editing the component definition, which rewrites every page that places it. A `section` block's children stay click-to-edit unconditionally. So when you choose a component, DECLARE A PROP \u2014 WITH A `target` \u2014 for every string, link and image a marketer will ever touch; a component with un-propped editable copy is the defect, not the component. In the dock an unset prop shows EMPTY and inherits the definition's default, so set props explicitly when you want the current copy visible there. Do not hand-write a band's JSON: start from a built-in section blueprint (list_components returns locked `builtin:*` blueprints with no projectId \u2014 hero-centered, hero-split, feature-grid-three, cta-banner and nine more), each already rooted in a `section` block with its editable leaves declared as props. INLINE its blockJson as a `section` block for a one-off band; for a recurring band, materialize the blueprint with create_component so it becomes project-scoped before implementation validation, Output or publication. Direct `builtin:*` component references exist only for legacy delivery compatibility. Two consecutive call-to-action buttons are two sibling `button` blocks inside the same section \u2014 never a `columns` block, which is a `repeat(N,1fr)` grid and would stretch each CTA to half the container. Buttons are inline-level and flow side by side on their own.";
19
+ var COMPONENT_PROP_TYPES = [
20
+ "text",
21
+ "richtext",
22
+ "image",
23
+ "url",
24
+ "boolean",
25
+ "number",
26
+ "select",
27
+ "group",
28
+ "table",
29
+ "slot",
30
+ "form"
31
+ ];
19
32
 
20
33
  // ../types/src/layout-lucide-icons.ts
21
34
  var LAYOUT_SECTION_ICONS = Object.freeze([
@@ -2578,6 +2591,11 @@ function buildToolDefs(deps) {
2578
2591
  "the page's VISUAL composition \u2014 how a components-first page is built. Place one `component` block per section: {type:'component', id:'<stable>', props:{componentId:'<id from create_component>'}}. The component must be PUBLISHED (publish_component) or it renders as nothing on the live site. Independent of `fields`, which is a typed schema for a site's own code to read."
2579
2592
  ),
2580
2593
  fields: z.array(fieldObject).optional().describe("the page's typed schema fields"),
2594
+ // Undeclared, this was stripped by the SDK before the request, so a singleton created with its
2595
+ // values arrived without them. The remote adapter and POST /management/pages always took it.
2596
+ data: z.record(z.string(), z.unknown()).optional().describe(
2597
+ "a SINGLETON's field VALUES keyed by field key \u2014 seeds its entry in the same call. If any value is refused the page is still created, none of `data` is saved, and the result names ENTRY_SEED_FAILED with the per-field reasons: fix them and write with set_page_content."
2598
+ ),
2581
2599
  metaTitle: z.string().optional().describe("SEO meta title"),
2582
2600
  metaDescription: z.string().optional().describe("SEO meta description")
2583
2601
  });
@@ -2730,7 +2748,12 @@ function buildToolDefs(deps) {
2730
2748
  max: z.number().optional().describe("'number' fields only \u2014 inclusive ceiling"),
2731
2749
  phoneFormat: z.enum(["any", "e164"]).optional().describe("'phone' fields only"),
2732
2750
  pattern: z.string().optional().describe("'text' / 'textarea' / 'url' fields only \u2014 a regex")
2733
- }).optional().describe("per-field rules the API enforces on submit; each key is only valid on the field types listed")
2751
+ }).optional().describe("per-field rules the API enforces on submit; each key is only valid on the field types listed"),
2752
+ ui: z.strictObject({
2753
+ countryPicker: z.boolean().optional().describe("'phone' fields only \u2014 render a country picker; the value is stored as E.164"),
2754
+ defaultCountry: z.string().optional().describe("'phone' fields only \u2014 uppercase ISO 3166-1 alpha-2 the picker starts on; requires countryPicker"),
2755
+ showFlags: z.boolean().optional().describe("'phone' fields only \u2014 show the country flag beside the dial code; requires countryPicker")
2756
+ }).optional().describe("per-field presentation options; each key is only valid on the field types listed")
2734
2757
  });
2735
2758
  const formSettingsShape = {
2736
2759
  description: z.string().optional(),
@@ -2761,10 +2784,12 @@ function buildToolDefs(deps) {
2761
2784
  key: z.string().min(1),
2762
2785
  label: z.string().min(1),
2763
2786
  target: z.object({ blockId: z.string().min(1), path: z.string().min(1) }),
2764
- // MUST stay at parity with componentPropDefSchema on the server. `update_component`
2765
- // REPLACES the whole `props` array, so a type this enum omits cannot be echoed back: an
2766
- // agent that reads a component and writes it back DESTROYS every prop of that type.
2767
- type: z.enum(["text", "richtext", "image", "url", "boolean", "number", "select", "group", "table", "slot"]),
2787
+ // Parity with componentPropDefSchema is no longer a promise to keep by hand — both now read
2788
+ // the SAME list from @bettercms-ai/types. It used to be a copy, and the failure that made
2789
+ // was quiet and total: `update_component` REPLACES the whole `props` array, so a type this
2790
+ // enum omitted could not be echoed back, and an agent reading a component and writing it
2791
+ // straight back DESTROYED every prop of that type on a 200.
2792
+ type: z.enum(COMPONENT_PROP_TYPES),
2768
2793
  // 'slot' holds ONE nested component instance; config.componentIds restricts what may
2769
2794
  // fill it. Absent here until now, so a slot allowlist was unreachable from stdio even
2770
2795
  // once the enum allowed the type.
@@ -3324,7 +3349,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3324
3349
  def(
3325
3350
  "get_project",
3326
3351
  "Get the connected project",
3327
- "Get the connected project's info \u2014 id, name, slug, subdomain, and its live URL (https://<handle>.bettercms.site). Use it to tell the user where their site is published / link the result. " + BRAND_KIT_NOTE,
3352
+ "Get the connected project's info \u2014 id, name, slug, subdomain, and its live URL (https://<handle>.bettercms.site). Use it to tell the user where their site is published / link the result. It also returns `useCdn` and `mediaBaseUrl` \u2014 the origin this project's media is minted and resized on. A deployed site's `PUBLIC_BCMS_MEDIA_URL` / `mediaUrl` must equal `mediaBaseUrl`, or resized images 404 on any rung but production. " + BRAND_KIT_NOTE,
3328
3353
  z.object({}).shape,
3329
3354
  async (c) => ok("Project.", await data(c, "GET", `/management/projects/current`))
3330
3355
  ),
@@ -3390,7 +3415,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3390
3415
  def(
3391
3416
  "set_media_delivery",
3392
3417
  "Choose where this site's media is served from",
3393
- "Record whether this project's media (images, video, files) is served from the BetterCMS CDN or from the API origin. `useCdn: true` is the DEFAULT and the recommendation: URLs are minted on the CDN host, or on the project's own CDN base when it stores media in its own bucket (BYOK). `useCdn: false` mints them on the API origin instead \u2014 the same service serving the same bytes, only under a different hostname. ASK THE USER; do not pick for them. Called without `useCdn`, this tool asks them directly (or hands you the question to ask). \u{1F534} IT APPLIES TO URLs MINTED FROM NOW ON \u2014 new uploads, entry saves, get_media. It does NOT rewrite URLs already stored in published content, and it does not need to: the old URLs keep resolving. To move an existing image, re-save the field that holds it. \u{1F534} IT IS THE BACKEND HALF ONLY. A deployed frontend builds its own transform URLs with @bettercms-ai/image-url, which has its own media host \u2014 set `PUBLIC_BCMS_MEDIA_URL` (or the integration's `mediaUrl` option) to match, or that site's resized images stay on the CDN while everything else moves. Read the current answer from get_project (`useCdn`). Re-callable; the last answer wins.",
3418
+ "Record whether this project's media (images, video, files) is served from the BetterCMS CDN or from the API origin. `useCdn: true` is the DEFAULT and the recommendation: URLs are minted on the CDN host, or on the project's own CDN base when it stores media in its own bucket (BYOK). `useCdn: false` mints them on the API origin instead \u2014 the same service serving the same bytes, only under a different hostname. ASK THE USER; do not pick for them. Called without `useCdn`, this tool asks them directly (or hands you the question to ask). \u{1F534} IT APPLIES TO URLs MINTED FROM NOW ON \u2014 new uploads, entry saves, get_media. It does NOT rewrite URLs already stored in published content, and it does not need to: the old URLs keep resolving. To move an existing image, re-save the field that holds it. \u{1F534} IT IS THE BACKEND HALF ONLY. A deployed frontend builds its own transform URLs with @bettercms-ai/image-url, which has its own media host \u2014 set `PUBLIC_BCMS_MEDIA_URL` (or the integration's `mediaUrl` option) to match, or that site's resized images stay on the CDN while everything else moves. Read the current answer from get_project: `useCdn` is the choice, `mediaBaseUrl` is the origin it produces \u2014 the value the frontend's `PUBLIC_BCMS_MEDIA_URL` must match. Re-callable; the last answer wins.",
3394
3419
  // Optional in the schema for the same reason `framework` and `preference` are: a
3395
3420
  // required arg is rejected by the SDK before the handler runs, which would kill the
3396
3421
  // elicitation below and leave the model guessing.
@@ -3440,7 +3465,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3440
3465
  def(
3441
3466
  "list_content_models",
3442
3467
  "List content models",
3443
- "List the content models (reusable schemas for dynamic collections like Blog/Products) in the connected project.",
3468
+ "List the content models (reusable schemas for dynamic collections like Blog/Products) in the connected project. A project-scoped connection sees its own project's models plus workspace-level ones, which it can read but not change.",
3444
3469
  z.object({}).shape,
3445
3470
  async (c) => ok("Content models.", await data(c, "GET", `/management/content/models`))
3446
3471
  ),
@@ -3477,7 +3502,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3477
3502
  def(
3478
3503
  "update_page",
3479
3504
  "Edit a page",
3480
- "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). It does NOT change the field SCHEMA \u2014 use add_page_field / set_page_content for that. Renaming the slug keeps content intact. Publishing copies the draft blocks live in the same call. 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,
3505
+ "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). 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_page_field / 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,
3481
3506
  z.object({ ...headPatchShape, noindex: z.boolean().optional().describe("hide this page from search engines and site search"), pageId: z.string().min(1), 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() }).shape,
3482
3507
  async (c, a) => {
3483
3508
  const res = await c.fetchJSON(
@@ -3488,10 +3513,9 @@ ${d.outline}` : "Proposed Content structure.", d);
3488
3513
  }
3489
3514
  );
3490
3515
  const note = res.redirectNote;
3491
- return ok(
3492
- note ? `Updated page. An existing redirect from ${note.source} already points to ${note.existingDestination}; it wasn't changed, so the old address does not redirect to the new one.` : "Updated page.",
3493
- res.data
3494
- );
3516
+ const summary = note ? `Updated page. An existing redirect from ${note.source} already points to ${note.existingDestination}; it wasn't changed, so the old address does not redirect to the new one.` : "Updated page.";
3517
+ return ok(res.warnings?.length ? `${summary}
3518
+ ${res.warnings.join("\n")}` : summary, res.data);
3495
3519
  }
3496
3520
  ),
3497
3521
  // ── Code + deploy (parity with remote /mcp; needs artifact:write) ──
@@ -3550,7 +3574,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3550
3574
  def(
3551
3575
  "get_binding_report",
3552
3576
  "Check what on the live site is editable",
3553
- "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.",
3577
+ "The receipt for 'is this site actually EDITABLE?'. Every release scans the built HTML for the element that renders each CMS field value; this returns what that scan found, per slot: `mode` ('text-match' = bindings guessed from rendered text, 'declared' = the template declares them), `pagesInspected`, `bound` (elements carrying a binding), and `unmatched` \u2014 per page, each path with its kind and the reason it failed (not-declared / ambiguous-text / no-element). DEPLOY FIRST: before any release there is no report and this answers pages 0, mode null, refreshRequired true. `refreshReason` says which of two states that is: `never-generated` \u2014 no report was ever written for this slot, and only a release BetterCMS itself builds and serves writes one, so a HEADLESS site never gets one and redeploying the same way will not change it (verify that site by fetching it); `outdated` \u2014 a report exists in an older shape, and the next release rewrites it. It certifies exactly one thing \u2014 that every non-empty field of every page has SOME element carrying its path. It cannot see copy that was never modelled, so diff each route's visible text against its entry values yourself before calling a page done. `builtRoutes` lists the routes the live build has HTML for (null when unknown: runtime release, >500 routes, or a report older than this field). `canvas.lane` names the live-preview lane this build gives the editor \u2014 `bridge` (the build ships BcmsDraftBridge), `draft-route` (a node runtime rendering drafts server-side) or `none`, in which case get_next_steps carries the recipe (playbook section 11); null means the report predates the field. `unaddressable` counts visible text on the live site that no field owns \u2014 the one thing `unmatched` structurally cannot see, because it only ever speaks about fields that already exist. EVERY release measures it server-side on every inspected page: `{count, pages, measuredAt, routes:[{path, count, visible, buckets:[{tag, context, chars, nodes, samples}]}]}`, where `path` is the route (the empty string is the home page), `visible` its countable characters (ornaments, skip links and the platform badge excluded from both sides) and `buckets` where the unowned text is, biggest first. Anything over `count / visible > 0.02` is copy the CMS cannot see: fix it in the SOURCE (wrap the run in an element the codemod can bind, or declare `<BcmsField path=\u2026>`), deploy, re-read \u2014 playbook section 11 has the loop. An author turning the editor's Unbound text control on posts a bare count of text NODES for the route they opened (no `visible`, no buckets); those rows are reported only for a build the release never measured, never summed with the release's characters. `skipReasons` says why pages were not inspected (`no-html`, `route-cap`, `authored-content` = the singleton lane declined the project, so NO route of it has a page), and `coverage.error` = `nothing-bound` means not one element of this build carries a binding, so the percentage beside it describes a site this build is not. `unaddressable` is measured PER SLOT; on a promote-gated project pass slot:'current' to read the tree the editor frames. Pass `slot` ('current' or 'staging') to read the other tree; the default is the slot this project's releases land in.",
3554
3578
  z.object({ slot: z.enum(["current", "staging"]).optional().describe("which release tree to read; defaults to the one this project deploys to") }).shape,
3555
3579
  async (c, a) => ok("Binding report.", await data(c, "GET", `/management/projects/current/binding-report${q({ slot: a.slot })}`))
3556
3580
  ),
@@ -3588,7 +3612,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3588
3612
  def(
3589
3613
  "componentize_sections",
3590
3614
  "Turn this site's derived sections into components",
3591
- "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT (its `sectionType` family, category 'section', placeable on any page) and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Call it with `dryRun: true` first \u2014 same receipt, nothing written. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. `copy` says WHO OWNS THE COPY. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` is the page-builder model \u2014 each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words. Run it again and it converts nothing and duplicates nothing. `sections` narrows a run to named groupKeys (`hero`, `group-fast`); a key the plan does not list on the pages you named is refused as `unknown-section` rather than quietly doing nothing. Every component it CREATES is filed into a Component Group (the folders of the dashboard's Components tab): the one you name in `group` (found or created), else its section family \u2014 nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A component it reuses keeps the Group it has; the receipt's `components.groups` lists the Groups it filed into.",
3615
+ "Turn this site's derived sections into components. CONFIRM WITH THE USER FIRST: show them get_componentize_plan's sections and say how many components it will create and which pages it will rewrite. It creates each proposed component as a DRAFT (its `sectionType` family, category 'section', placeable on any page) and replaces each page's DRAFT blocks with an ordered list of `component` instances \u2014 one per group, each carrying `props.bind: \"<groupKey>\"`, which points at the page field group that already holds the copy. So nothing is copied and nothing moves: the page keeps its `fields`, its values and its bindings, click-to-edit keeps working and the coverage meter does not change. Pass the plan's `digest`; a 409 `stale-plan` means the site changed since you read that plan, so read it again, show the user what changed and confirm again. Call it with `dryRun: true` first \u2014 same receipt, nothing written. Read its `components.wouldDuplicate` before applying: each id is an existing component of the same family this run would sit a NEW one beside, because reuse is by identity and slug, never by family. Place those by hand or accept the second component knowingly. Running it twice is safe: a group that already has a placement comes back in `sections.pending` as ALREADY_COMPONENTIZED and no second component is created. DRAFTS ONLY \u2014 an unpublished component renders as an EMPTY STRING on the live site, so publish_component each one and publish the pages before this reaches a visitor. Then run `npx @bettercms-ai/convert --componentize` in the repo so its templates render these sections from `pages[].blocks`. `copy` says WHO OWNS THE COPY. The default `bind` is the above: the words stay in the page's field group. `copy: \"instance\"` is the page-builder model \u2014 each placement takes that group's CURRENT draft values into its own `props.overrides` (a repeater becomes one table prop holding every row), records which page path each came from in `props.source`, and drops `props.bind`; the page's fields are KEPT and marked `origin: \"componentized\"`, so nothing is lost and the editor stops showing a second place to type the same words. Run it again and it converts nothing and duplicates nothing. `sections` narrows a run to named groupKeys (`hero`, `group-fast`); a key the plan does not list on the pages you named is refused as `unknown-section` rather than quietly doing nothing. Every component it CREATES is filed into a Component Group (the folders of the dashboard's Components tab): the one you name in `group` (found or created), else its section family \u2014 nav, header, footer or menu \u2192 Layout, an unnamed section \u2192 Sections, otherwise the family name (Hero, FAQ). A component it reuses keeps the Group it has; the receipt's `components.groups` lists the Groups it filed into.",
3592
3616
  z.object({
3593
3617
  digest: z.string().min(1).describe("The `digest` get_componentize_plan returned. A different one is refused with 409 stale-plan."),
3594
3618
  pageIds: z.array(z.string().min(1)).optional().describe("Componentize only these pages (ids from the plan). Omit for every page the plan lists."),
@@ -3835,13 +3859,18 @@ ${d.outline}` : "Proposed Content structure.", d);
3835
3859
  // Cast: the SDK's ContentModelField types `options` as string[], narrower than the API,
3836
3860
  // which also takes {label, value} (backend selectFieldSchema).
3837
3861
  ...args.fields ? { fields: args.fields.map(toField) } : {},
3862
+ ...args.data !== void 0 ? { data: args.data } : {},
3838
3863
  ...args.metaTitle !== void 0 ? { metaTitle: args.metaTitle } : {},
3839
3864
  ...args.metaDescription !== void 0 ? { metaDescription: args.metaDescription } : {}
3840
3865
  });
3841
- return ok(
3842
- `Created ${page.pageType ?? "page"} page '${page.title}' (id ${page.id}, slug ${page.slug}) with ${page.fields.length} field(s).`,
3843
- page
3844
- );
3866
+ const { warnings, seedErrors, ...created } = page;
3867
+ const summary = `Created ${created.pageType ?? "page"} page '${created.title}' (id ${created.id}, slug ${created.slug}) with ${created.fields.length} field(s).`;
3868
+ const notes = [
3869
+ ...warnings ?? [],
3870
+ ...seedErrors ? Object.entries(seedErrors).map(([key, why]) => ` ${key}: ${why}`) : []
3871
+ ];
3872
+ return ok(notes.length ? `${summary}
3873
+ ${notes.join("\n")}` : summary, created);
3845
3874
  })
3846
3875
  )
3847
3876
  },
@@ -3945,9 +3974,11 @@ ${d.outline}` : "Proposed Content structure.", d);
3945
3974
  ...args.slug !== void 0 ? { slug: args.slug } : {},
3946
3975
  ...args.data !== void 0 ? { data: args.data } : {}
3947
3976
  });
3948
- const entry = args.status !== void 0 && args.status !== "draft" ? await client.updateEntry(created.id, { status: args.status }) : created;
3977
+ const published = args.status !== void 0 && args.status !== "draft" ? await client.updateEntry(created.id, { status: args.status }) : created;
3978
+ const { warnings, ...entry } = published;
3979
+ const notes = [.../* @__PURE__ */ new Set([...created.warnings ?? [], ...warnings ?? []])];
3949
3980
  return ok(
3950
- `Created entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`,
3981
+ [`Created entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...notes].join("\n"),
3951
3982
  entry
3952
3983
  );
3953
3984
  })
@@ -3977,7 +4008,7 @@ ${d.outline}` : "Proposed Content structure.", d);
3977
4008
  name: "list_content_entries",
3978
4009
  config: {
3979
4010
  title: "List content entries (incl. drafts)",
3980
- description: "List content entries \u2014 including drafts \u2014 filtered by model and/or page. Use it to SEE existing content before editing. For a singleton page, pass its pageId to get its single entry.",
4011
+ description: "List content entries \u2014 including drafts \u2014 filtered by model and/or page. Use it to SEE existing content before editing. For a singleton page, pass its pageId to get its single entry. A project-scoped connection sees its own project's entries plus workspace-level ones, which it can read but not change.",
3981
4012
  inputSchema: listEntriesInput.shape
3982
4013
  },
3983
4014
  handler: guard(
@@ -4009,18 +4040,18 @@ ${d.outline}` : "Proposed Content structure.", d);
4009
4040
  name: "update_content_entry",
4010
4041
  config: {
4011
4042
  title: "Update a content entry's values",
4012
- 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. Use this to edit an existing entry; for a singleton page prefer set_page_content. " + ENTRY_META_NOTE,
4043
+ 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. 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, read the entry (get_content_entry), change that key, and send the whole object back. " + ENTRY_META_NOTE,
4013
4044
  inputSchema: updateEntryInput.shape
4014
4045
  },
4015
4046
  handler: guard(
4016
4047
  async (args) => withClient(async (client) => {
4017
- const entry = await client.updateEntry(args.entryId, {
4048
+ const { warnings, ...entry } = await client.updateEntry(args.entryId, {
4018
4049
  ...args.data !== void 0 ? { data: args.data } : {},
4019
4050
  ...args.status !== void 0 ? { status: args.status } : {},
4020
4051
  ...args.slug !== void 0 ? { slug: args.slug } : {},
4021
4052
  ...args.meta !== void 0 ? { meta: args.meta } : {}
4022
4053
  });
4023
- return ok(`Updated entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, entry);
4054
+ return ok([`Updated entry '${entry.slug}' (id ${entry.id}, status ${entry.status}).`, ...warnings ?? []].join("\n"), entry);
4024
4055
  })
4025
4056
  )
4026
4057
  },
@@ -4327,7 +4358,7 @@ ${d.outline}` : "Proposed Content structure.", d);
4327
4358
  name: "publish_components",
4328
4359
  config: {
4329
4360
  title: "Publish many components in one call",
4330
- description: "Publish up to 100 components in ONE call \u2014 the same act as publish_component, once per item, copying each DRAFT definition to the live copy. Each item reports `{ ok, status, error, readiness }` and a failure never stops the run. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: it means this exact variant still needs passing implementation evidence and a HUMAN's approval in the BetterCMS dashboard, which no tool on this connection can grant and which has no bulk form. The item's `readiness` names what is missing \u2014 relay that to the user as the list of what only they can approve. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish; 422 means the publish would break a Layout, and the item names which.",
4361
+ description: "Publish up to 100 components in ONE call \u2014 the same act as publish_component, once per item, copying each DRAFT definition to the live copy. Each item reports `{ ok, status, error, readiness }` and a failure never stops the run. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it, and it has no bulk form. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. Report its `readiness`. The item's `readiness` names what is missing \u2014 relay that to the user as the list of what only they can approve. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish; 422 means the publish would break a Layout, and the item names which.",
4331
4362
  inputSchema: publishComponentsInput.shape
4332
4363
  },
4333
4364
  handler: guard(
@@ -4521,7 +4552,7 @@ ${lines.join("\n")}`, found);
4521
4552
  name: "publish_component",
4522
4553
  config: {
4523
4554
  title: "Publish a component",
4524
- description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. create_component and update_component write drafts, and an unpublished component renders as NOTHING live, with no error. Publish every component you place on a page. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: this exact variant still needs passing implementation evidence and a HUMAN's approval in the BetterCMS dashboard, which no tool on this connection can grant. The response's `readiness` names what is missing \u2014 relay it to the user instead of retrying or routing around it. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
4555
+ description: "Publish one project-scoped component variant after its exact implementation evidence and human approval are ready: copy its DRAFT definition to the live copy and re-bake every published page that embeds it. This is the ONLY way a component reaches the live site. Built-in `builtin:*` rows are locked blueprints, not implementations; materialize one with create_component first. Workspace-global component publication is currently fail-closed. create_component and update_component write drafts, and an unpublished component renders as NOTHING live, with no error. Publish every component you place on a page. 403 PUBLISH_NOT_GRANTED means this connection may author but not publish: say so and let the user publish from the dashboard. \u{1F534} A 409 COMPONENT_IMPLEMENTATION_NOT_READY is NOT retried and must not be: no tool on this connection can clear it. It is never a component you just made with create_component in no variant group \u2014 that is dashboard-managed and publishes on the preflight alone. The refusal is for a REPO-managed component without exact validation evidence and a human's approval, or for a variant-group member (the provisioned navigation and footer) whose group's canonical-input contract is not published. Report its `readiness`. The response's `readiness` names what is missing \u2014 relay it to the user instead of retrying or routing around it. 422 means publishing would break a Layout that uses it \u2014 the response names which.",
4525
4556
  inputSchema: getComponentInput.shape
4526
4557
  },
4527
4558
  handler: guard(
@@ -5078,7 +5109,10 @@ axis: it is the picker TAB (hero / content / social-proof / conversion for secti
5078
5109
  footer for chrome; form; custom for page-specific).
5079
5110
 
5080
5111
  **4. Editor fields are \`props\`.** Types: \`text richtext image url boolean number select group
5081
- table slot\`. Always set \`defaultValue\`. \`required\`, \`helpText\` and \`placeholder\` are editor
5112
+ table slot form\`. Always set \`defaultValue\`. \`form\` points at ONE Form document: target a
5113
+ \`form\` block's \`props\` and set \`defaultValue\` to \`{formId}\`. NEVER re-declare a form's fields
5114
+ as props \u2014 the Form document owns them, a second copy drifts on the first edit, and the Forms
5115
+ tab is where an author edits them. \`required\`, \`helpText\` and \`placeholder\` are editor
5082
5116
  hints the dashboard renders \u2014 they are NOT enforced at write time, so an override missing a
5083
5117
  required prop still saves; \`get_site_composition\` is what reports those. Use \`select\` with
5084
5118
  \`config.options\` for layout / theme / alignment / spacing variants that change only styling,
@@ -5284,7 +5318,7 @@ conversion is yours to write.
5284
5318
  \`{...bcms.home.hero.title}\` / \`{...bcms.blog.features.$(i)}\`. The hand form is
5285
5319
  \`data-bcms-field="<path>"\` (plus \`data-bcms-kind="richtext"|"image"\`), the \xA711 layout markers
5286
5320
  for nav and footer, and \`<div data-bcms-field="body" data-bcms-kind="document">\` around a
5287
- Portable Text render.
5321
+ Portable Text render, whose per-block elements carry their own \`data-bcms-kind="richtext"\`.
5288
5322
  **A value that lives in an ATTRIBUTE \u2014 an \`href\`, an \`alt\`, an \`src\` \u2014 rides
5289
5323
  \`data-bcms-props\`, and its grammar is PIPES, NOT JSON:**
5290
5324
  \`data-bcms-props="<path>|<kind>|<domAttribute>"\`, semicolon-separated for several on one
@@ -5553,7 +5587,9 @@ Author a form (then the user embeds it with \`<BcmsForm form={getForm('Name')} /
5553
5587
  \`options\`), radio (needs \`options\`), number, phone, date, url, consent, hidden. Optional per
5554
5588
  field: required?, placeholder?, helpText? (shown under the input), defaultValue?,
5555
5589
  \`validation\` (\`emailPolicy\` on email, \`min\`/\`max\` on number, \`phoneFormat\` on phone,
5556
- \`pattern\` on text/textarea/url \u2014 a rule on any other type is a 400), and
5590
+ \`pattern\` on text/textarea/url \u2014 a rule on any other type is a 400),
5591
+ \`ui\` (\`countryPicker\` + \`defaultCountry\` on phone \u2014 renders a country picker and stores
5592
+ E.164; \`defaultCountry\` without \`countryPicker\` is a 400), and
5557
5593
  \`showIf: { field, equals }\` for conditional display.
5558
5594
  3. **Settings** \u2014 name (used by getForm('Name')), submitLabel?, successMessage?, redirectUrl?.
5559
5595
  4. **Confirm**, then \`create_form\` { name, fields, ... } (returns the new id), or
@@ -5574,8 +5610,11 @@ error-prone, so go slow and confirm. Never guess the layout.
5574
5610
  the same prop keys across a family or a swap drops content.
5575
5611
  4. **Props** \u2014 declare only what should really be editable:
5576
5612
  { key, label, target: { blockId, path },
5577
- type: text|richtext|image|url|boolean|number|select|group|table|slot }. 'select' needs
5578
- config.options; 'group'/'table' need config.fields; 'slot' holds ONE nested component.
5613
+ type: text|richtext|image|url|boolean|number|select|group|table|slot|form }. 'select' needs
5614
+ config.options; 'group'/'table' need config.fields; 'slot' holds ONE nested component;
5615
+ 'form' holds ONE Form document \u2014 point target.path at 'props' on an empty form block and set
5616
+ defaultValue to {formId}. Never re-declare a form's FIELDS as props: the Form document owns
5617
+ them, and a second copy is a second source of truth that drifts on the first edit.
5579
5618
  5. **Confirm the structure** (AskUserQuestion: show the block tree), then \`create_component\`.
5580
5619
  6. **\`publish_component\`.** It lands as a DRAFT, and a draft component renders as NOTHING on
5581
5620
  the live site \u2014 no error, no placeholder. This step is not optional.